openvibers/openvibe.bot Commits
Bot: Network keys and token checks from openvibe-sdk/auth (async, control socket authorized before upgrade) (#71)
Changes
@@@ -44,7 +44,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).- OpenRestream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.--- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.38.0 and `openvibe-shared` v3.0.0 (package.json).++- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.40.0 and `openvibe-shared` v3.0.0 (package.json).- Account export and deletion (ADR-033): Network grants `events.subscription.manage` (openvibe.events) for the twosubscriptions created at boot, then, last and once the release is live, `network.account.export.contribute` and`network.account.deletion.confirm` (openvibe.network).@@@ -125,6 +125,6 @@ OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json<!-- versions:start -->- openvibe-contracts: v0.129.0--- openvibe-sdk: v0.38.0++- openvibe-sdk: v0.40.0- openvibe-shared: v3.0.0<!-- versions:end -->@@@ -6,11 +6,11 @@"port": 4630,"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)","contracts": "openvibe-contracts v0.129.0",-- "sdk": "openvibe-sdk v0.38.0",++ "sdk": "openvibe-sdk v0.40.0","shared": "openvibe-shared v3.0.0","packages": ["openvibe-contracts v0.129.0",-- "openvibe-sdk v0.38.0",++ "openvibe-sdk v0.40.0","openvibe-shared v3.0.0"],"capabilities": [@@@ -13,15 +13,15 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "jsonwebtoken": "^9.0.2","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","ws": "^8.18.0"},"devDependencies": {-- "@electric-sql/pglite": "^0.5.8"++ "@electric-sql/pglite": "^0.5.8",++ "jsonwebtoken": "^9.0.3"},"engines": {"node": ">=22.0.0"@@@ -860,9 +860,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"@@@ -25,9 +25,8 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "jsonwebtoken": "^9.0.2","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","ws": "^8.18.0"@@@ -36,6 +35,7 @@"node": ">=22.0.0"},"devDependencies": {-- "@electric-sql/pglite": "^0.5.8"++ "@electric-sql/pglite": "^0.5.8",++ "jsonwebtoken": "^9.0.3"}}@@@ -12,7 +12,10 @@** A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.*/--const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++const contracts = require('openvibe-contracts');++const { verifyServiceToken } = require('openvibe-sdk/auth');++++const { capabilities, http, ids } = contracts;const PRINCIPAL_SUB = /^(svc|app|mod|node):/;const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;@@@ -35,12 +38,21 @@ function userPrincipal(claims) {};}++/**++ * The pinned contracts rules with the key the token's kid names (openvibe-sdk/auth verifyServiceToken). No key yet is++ * identity.unavailable (a 503), as before.++ */++async function principalToken(token, { keyOptions, issuer, audience }) {++ const r = await verifyServiceToken(token, { ...keyOptions, issuer, audience, contracts });++ if (!r.ok && r.code === 'token.unavailable') return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };++ return r;++}++/** Verify a service token (audience openvibe.bot). Returns { ok, claims } or { ok:false, code, reason }. */--function verifyService(token, { publicKey, issuer, audience }) {-- if (!publicKey) return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };++async function verifyService(token, opts) {const payload = decodePayload(token);if (!payload || typeof payload.sub !== 'string' || !PRINCIPAL_SUB.test(payload.sub)) return { ok: false, code: 'token.invalid', reason: 'not a service token' };-- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer, audience });++ const r = await principalToken(token, opts);if (!r.ok) return { ok: false, code: r.code, reason: r.reason };return { ok: true, claims: r.claims };}@@@ -55,9 +67,8 @@ function isNodeToken(token) {* Verify a node token: signed by Network, audience openvibe.bot, actor_type node, sub node:nod_…* Returns { ok, principal: 'nod_…', claims } or { ok:false, code, reason }.*/--function verifyNode(token, { publicKey, issuer, audience }) {-- if (!publicKey) return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };-- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer, audience });++async function verifyNode(token, opts) {++ const r = await principalToken(token, opts);if (!r.ok) return { ok: false, code: r.code, reason: r.reason };const m = NODE_SUB.exec(String(r.claims.sub || ''));if (r.claims.actor_type !== 'node' || !m) return { ok: false, code: 'token.invalid', reason: 'not a node token' };@@@ -65,24 +76,25 @@ function verifyNode(token, { publicKey, issuer, audience }) {}function createApiAuth({ config, keys, userAuth }) {-- function resolve(req) {++ const opts = () => ({ keyOptions: keys.verifyOptions, issuer: config.network.issuer, audience: config.audience });++++ async function resolve(req) {const header = String(req.headers.authorization || '');if (!header.startsWith('Bearer ')) return { principal: ANON };const token = header.slice(7).trim();-- const publicKey = keys.get();-- if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };++ if (!keys.loaded()) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };const payload = decodePayload(token);if (payload && payload.actor_type === 'node') {-- const r = verifyNode(token, { publicKey, issuer: config.network.issuer, audience: config.audience });++ const r = await verifyNode(token, opts());if (!r.ok) return { error: [401, r.code, r.reason] };return { principal: { kind: 'node', principal: r.principal, sub: r.claims.sub, jti: r.claims.jti } };}if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {-- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });-- if (!r.ok) return { error: [401, r.code, r.reason] };++ const r = await principalToken(token, opts());++ if (!r.ok) return { error: [r.code === 'identity.unavailable' ? 503 : 401, r.code, r.reason] };return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti } };}-- const claims = userAuth.verify(token);++ const claims = await userAuth.verify(token);if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };const p = userPrincipal(claims);if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };@@@ -90,10 +102,11 @@ function createApiAuth({ config, keys, userAuth }) {}function middleware(req, res, next) {-- const r = resolve(req);-- if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });-- req.principal = r.principal;-- return next();++ resolve(req).then((r) => {++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });++ req.principal = r.principal;++ return next();++ }, next);}const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);@@@ -34,7 +34,7 @@ async function main() {const app = createApp({ config, db, valkey, registry });const { domain, keys, outbox, usage, hub, sim, onvif } = app.locals;-- keys.start();++ keys.start().catch(() => { /* logged by the JWKS client */ });const timers = [];if (config.jobs.enabled) {@@@ -3,49 +3,24 @@/*** 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). It verifies service tokens-- * (audience openvibe.bot) and the browser's Network user JWT, both offline.++ * 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). They verify service and node tokens (audience openvibe.bot) and the browser's Network++ * user JWT, all offline (openvibe-sdk/auth).* nodes Network's node principals (machines paired for Bot): POST /internal/node-pairings (a pairing* code, BOT_PAIRING_AUTHORITY=network), GET /internal/node-principals/:id and POST …/:id/revoke with* Bot's own svc:bot token (audience openvibe.network, network.node.manage).* identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the* names of subjects the robot's owner added.*/--const crypto = require('crypto');--const jwt = require('jsonwebtoken');const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');++const { createNetworkKeys, verifyUserToken } = require('openvibe-sdk/auth');const { serviceAuth } = require('openvibe-contracts');const { BotError } = require('./util');++/** 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');-- 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(`[Bot] 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 () => { await 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 });}/** The OAuth client of the browser session layer plus offline verification of the Network user JWT. */@@@ -58,19 +33,17 @@ function createUserAuth(config, keys) {authBase: config.network.url,internalBase: config.network.internalUrl,});-- /** Claims of a valid user token, or null. Service tokens are never user tokens. */-- function verify(token) {++ /**++ * Claims of a valid user token, or null. openvibe-sdk/auth verifyUserToken refuses service, app and mod principals++ * and typed tokens (a realtime ticket, a FedCM assertion, an export token); a node token (a machine paired for Bot)++ * is refused here too.++ */++ async function verify(token) {if (!token) return null;-- const key = keys.get();-- if (!key) return null;let claims;-- try { claims = jwt.verify(token, key, { algorithms: ['RS256'], issuer: config.network.issuer }); } catch { return null; }-- if (!claims || typeof claims !== 'object') return null;-- if (typeof claims.sub === 'string' && /^(svc|app|mod|node):/.test(claims.sub)) return null;-- if (claims.actor_type === 'service' || claims.actor_type === 'node') return null;-- // A typed token is never a session: a realtime ticket (typ realtime) or a FedCM assertion (typ fedcm, issued to-- // another site) is signed by the same key, and this check takes no audience (openvibe-sdk/auth's rule).-- if (claims.typ !== undefined || claims.purpose !== undefined) return null;++ try { claims = await verifyUserToken(String(token), { ...keys.verifyOptions, issuer: config.network.issuer }); } catch { return null; }++ if (typeof claims.sub === 'string' && /^node:/.test(claims.sub)) return null;++ if (claims.actor_type === 'node') return null;return claims;}return { client, verify };@@@ -35,7 +35,7 @@ function createBotReadiness({ db, valkey = null, keys, config, outbox, hub = nulchecks: [{ name: 'db', required: true, check: () => db.ready() },{ name: 'valkey', required: false, check: () => (valkey ? valkey.ready() : skip('VALKEY_URL unset: per-actor limits are per process (one process only)')) },-- { name: 'network_jwks', required: false, check: () => (keys.get() ? true : 'Network signing key not loaded yet: tokens cannot be verified') },++ { name: 'network_jwks', required: false, check: () => (keys.loaded() ? true : 'Network signing key not loaded yet: tokens cannot be verified') },{ name: 'events', required: false, cacheMs: outbox.enabled ? PING_TTL_MS : 0, timeoutMs: 2500, check: events },],details: async (body) => (body.checks.db.status === 'ok'@@@ -83,7 +83,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datlet path;try { path = new URL(req.url, 'http://localhost').pathname; } catch { return socket.destroy(); }if (path === '/device') return deviceWss.handleUpgrade(req, socket, head, (ws) => onDeviceSocket(ws, req));-- if (path === '/control') return controlWss.handleUpgrade(req, socket, head, (ws) => onControlSocket(ws, req));++ // Authorized before the upgrade is accepted: the socket's handlers exist from its first frame on.++ if (path === '/control') {++ return authorizeControl(req).then(++ (principal) => controlWss.handleUpgrade(req, socket, head, (ws) => onControlSocket(ws, req, principal)),++ (e) => { log.warn(`[Bot] control upgrade: ${e.message}`); socket.destroy(); });++ }if (path === '/watch') return watchWss.handleUpgrade(req, socket, head, (ws) => onWatchSocket(ws, req));socket.destroy();}@@@ -157,6 +162,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconn.backlog = null;}++ /** What the openvibe-sdk/auth checks in api/auth.js take: Network's keys, issuer and Bot's audience. */++ const tokenOpts = () => ({ keyOptions: keys.verifyOptions, issuer: config.network.issuer, audience: config.audience });++async function authenticateDevice(conn, credential) {const device = await domain.devices.byCredential(credential);if (!device) throw new Error('unknown or revoked credential');@@@ -165,15 +173,15 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat/** A node token: verified offline, then bound to its device (created on first use from Network's record). */async function authenticateNode(conn, nodeToken) {-- const r = verifyNode(nodeToken, { publicKey: keys.get(), issuer: config.network.issuer, audience: config.audience });++ const r = await verifyNode(nodeToken, tokenOpts());if (!r.ok) throw new Error(`node token refused: ${r.code}`);const device = await domain.devices.bindNode(r.principal);conn.node = { principal: r.principal, reauthBy: now() + config.device.nodeReauthMs };attachDevice(conn, device);}/** `reauth`: a fresh node token for the same principal moves the deadline; anything else leaves it. */-- function onReauth(conn, msg) {-- const r = verifyNode(typeof msg.token === 'string' ? msg.token : '', { publicKey: keys.get(), issuer: config.network.issuer, audience: config.audience });++ async function onReauth(conn, msg) {++ const r = await verifyNode(typeof msg.token === 'string' ? msg.token : '', tokenOpts());if (!r.ok) return sendError(conn, 'bot.reauth_refused', r.reason);if (r.principal !== conn.node.principal) return sendError(conn, 'bot.reauth_refused', 'the token is for another machine');conn.node.reauthBy = now() + config.device.nodeReauthMs;@@@ -364,25 +372,24 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat}// ── Operator side ─────────────────────────────────────────────────────────────────────────────-- function authorizeControl(req) {++ async function authorizeControl(req) {const cookie = parseCookies(req.headers.cookie)[ACCESS_COOKIE];-- if (cookie) { const claims = userAuth.verify(cookie); const p = userPrincipal(claims); if (p) return p; }++ if (cookie) { const claims = await userAuth.verify(cookie); const p = userPrincipal(claims); if (p) return p; }const token = bearer(req);if (!token) return null;const payload = decodePayload(token);if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {-- const r = verifyService(token, { publicKey: keys.get(), issuer: config.network.issuer, audience: config.audience });++ const r = await verifyService(token, tokenOpts());if (!r.ok || !capabilities.grants(r.claims.cap || [], 'bot.robot.control')) return null;const subject = String(req.headers['x-ov-subject'] || '');if (!isSubject(subject)) return null;return { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], subject };}-- const claims = userAuth.verify(token);++ const claims = await userAuth.verify(token);return userPrincipal(claims);}-- function onControlSocket(ws, req) {-- const principal = authorizeControl(req);++ function onControlSocket(ws, req, principal) {if (!principal) return ws.close(4002, 'sign in required');const conn = { ws, seq: 0, principal, subject: principal.subject, robotId: null, role: null };ws.conn = conn;@@@ -60,10 +60,11 @@ function withParam(target, key, value) {function viewerMiddleware(userAuth) {return (req, _res, next) => {const token = req.cookies && req.cookies[ACCESS_COOKIE];-- const claims = token ? userAuth.verify(token) : null;-- req.viewer = claims ? userPrincipal(claims) : null;-- req.viewerClaims = claims ? claimsToUser(claims) : null;-- next();++ Promise.resolve(token ? userAuth.verify(token) : null).then((claims) => {++ req.viewer = claims ? userPrincipal(claims) : null;++ req.viewerClaims = claims ? claimsToUser(claims) : null;++ next();++ }, next);};}@@@ -111,9 +112,9 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }throw lastErr || new Error('Network unreachable');}-- router.get('/login', (req, res) => {++ router.get('/login', async (req, res) => {const silent = !!req.query.silent && req.query.silent !== '0';-- if (silent && userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }++ if (silent && await userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);let target = url;if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }@@@ -162,7 +163,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });setSession(res, data.access_token, data.refresh_token);-- return res.json({ ok: true, user: data.user || claimsToUser(userAuth.verify(data.access_token)) });++ return res.json({ ok: true, user: data.user || claimsToUser(await userAuth.verify(data.access_token)) });} catch (err) {if (err.status && err.status < 500) return res.status(401).json({ error: err.error || 'invalid_grant' });return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });@@@ -185,9 +186,9 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }res.redirect(sanitizeNext(req.query.next, config));});-- router.get('/me', (req, res) => {++ router.get('/me', async (req, res) => {const token = (String(req.headers.authorization || '').startsWith('Bearer ') ? req.headers.authorization.slice(7) : null) || (req.cookies && req.cookies[ACCESS_COOKIE]);-- const claims = userAuth.verify(token);++ const claims = await userAuth.verify(token);if (!claims) return res.status(401).json({ error: 'Not authenticated' });res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });});@@@ -198,7 +199,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }try {const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });setSession(res, data.access_token, data.refresh_token);-- return res.json({ token: data.access_token, user: claimsToUser(userAuth.verify(data.access_token)) });++ return res.json({ token: data.access_token, user: claimsToUser(await userAuth.verify(data.access_token)) });} catch (err) {if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });@@@ -37,9 +37,9 @@ const { boot, check, done } = require('./helpers/app');await check('a typed Network token (realtime ticket, FedCM assertion) is never a session, even signed by the Network key', async () => {assert.strictEqual((await t.call('GET', '/api/v1/robots', { user: alex })).status, 200, 'a session token works');-- for (const extra of [{ typ: 'realtime', purpose: 'realtime' }, { typ: 'fedcm', aud: 'https://elsewhere.example' }]) {++ for (const extra of [{ typ: 'realtime', purpose: 'realtime' }, { typ: 'fedcm', aud: 'https://elsewhere.example' }, { purpose: 'export' }]) {const r = await t.call('GET', '/api/v1/robots', { token: t.network.signUser(alex, extra) });-- assert.strictEqual(r.status, 401, `${extra.typ}: ${r.status}`);++ assert.strictEqual(r.status, 401, `${extra.typ || extra.purpose}: ${r.status}`);}});@@@ -274,6 +274,15 @@ const plugins = require('./helpers/plugins');assert.strictEqual(await weak.waitForClose(), 4002, 'without bot.robot.control a service cannot control');const anon = await t.ws('/control');assert.strictEqual(await anon.waitForClose(), 4002, 'an anonymous socket is refused');++ // The socket is authorized before its upgrade is accepted (openvibe-sdk/auth checks are async): a typed Network++ // token is never a session there, as the cookie or as a Bearer.++ for (const extra of [{ typ: 'realtime', purpose: 'realtime' }, { typ: 'fedcm' }, { purpose: 'export' }]) {++ const typed = t.network.signUser(bob, extra);++ const viaCookie = await t.ws('/control', { headers: { Cookie: `ov_token=${typed}` } });++ assert.strictEqual(await viaCookie.waitForClose(), 4002, `${JSON.stringify(extra)} as the cookie`);++ const viaBearer = await t.ws('/control', { headers: { Authorization: `Bearer ${typed}` } });++ assert.strictEqual(await viaBearer.waitForClose(), 4002, `${JSON.stringify(extra)} as a Bearer`);++ }});await check('a service token forwards a command over HTTP and the device\'s ack comes back', async () => {@@@ -58,7 +58,7 @@ async function boot(opts = {}) {const store = opts.db ? { db: opts.db, store: opts.db.store, close: async () => {} } : await testDb({ store: opts.store });await seedProfiles(store.db, { log: { log() {} } });const app = createApp({ config, db: store.db, valkey: opts.valkey || null, now: () => Date.now() + clock.offset, log, ...(opts.appOpts || {}) });-- await app.locals.keys.load();++ await app.locals.keys.refresh();const server = await new Promise((resolve) => {const s = http.createServer(app);s.on('upgrade', (req, socket, head) => app.locals.hub.handleUpgrade(req, socket, head));
@@ -44,7 +44,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -44,7 +44,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).
- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).
- OpenRestream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenRestream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.
-- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.38.0 and `openvibe-shared` v3.0.0 (package.json).
+- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.40.0 and `openvibe-shared` v3.0.0 (package.json).
- Account export and deletion (ADR-033): Network grants `events.subscription.manage` (openvibe.events) for the two
- Account export and deletion (ADR-033): Network grants `events.subscription.manage` (openvibe.events) for the two
subscriptions created at boot, then, last and once the release is live, `network.account.export.contribute` and
subscriptions created at boot, then, last and once the release is live, `network.account.export.contribute` and
`network.account.deletion.confirm` (openvibe.network).
`network.account.deletion.confirm` (openvibe.network).
@@ -125,6 +125,6 @@ OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json
@@ -125,6 +125,6 @@ OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json
<!-- 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 -->
@@ -6,11 +6,11 @@
@@ -6,11 +6,11 @@
"port": 4630,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"contracts": "openvibe-contracts v0.129.0",
"contracts": "openvibe-contracts v0.129.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",
"packages": [
"packages": [
"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"
],
],
"capabilities": [
"capabilities": [
@@ -13,15 +13,15 @@
@@ -13,15 +13,15 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "jsonwebtoken": "^9.0.2",
"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",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.3"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -860,9 +860,9 @@
@@ -860,9 +860,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"
@@ -25,9 +25,8 @@
@@ -25,9 +25,8 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "jsonwebtoken": "^9.0.2",
"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",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -36,6 +35,7 @@
@@ -36,6 +35,7 @@
"node": ">=22.0.0"
"node": ">=22.0.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.3"
}
}
}
}
@@ -12,7 +12,10 @@
@@ -12,7 +12,10 @@
*
*
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.
*/
*/
-const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+const contracts = require('openvibe-contracts');
+const { verifyServiceToken } = require('openvibe-sdk/auth');
+
+const { capabilities, http, ids } = contracts;
const PRINCIPAL_SUB = /^(svc|app|mod|node):/;
const PRINCIPAL_SUB = /^(svc|app|mod|node):/;
const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;
const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;
@@ -35,12 +38,21 @@ function userPrincipal(claims) {
@@ -35,12 +38,21 @@ function userPrincipal(claims) {
};
};
}
}
+/**
+ * The pinned contracts rules with the key the token's kid names (openvibe-sdk/auth verifyServiceToken). No key yet is
+ * identity.unavailable (a 503), as before.
+ */
+async function principalToken(token, { keyOptions, issuer, audience }) {
+ const r = await verifyServiceToken(token, { ...keyOptions, issuer, audience, contracts });
+ if (!r.ok && r.code === 'token.unavailable') return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };
+ return r;
+}
+
/** Verify a service token (audience openvibe.bot). Returns { ok, claims } or { ok:false, code, reason }. */
/** Verify a service token (audience openvibe.bot). Returns { ok, claims } or { ok:false, code, reason }. */
-function verifyService(token, { publicKey, issuer, audience }) {
- if (!publicKey) return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };
+async function verifyService(token, opts) {
const payload = decodePayload(token);
const payload = decodePayload(token);
if (!payload || typeof payload.sub !== 'string' || !PRINCIPAL_SUB.test(payload.sub)) return { ok: false, code: 'token.invalid', reason: 'not a service token' };
if (!payload || typeof payload.sub !== 'string' || !PRINCIPAL_SUB.test(payload.sub)) return { ok: false, code: 'token.invalid', reason: 'not a service token' };
- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer, audience });
+ const r = await principalToken(token, opts);
if (!r.ok) return { ok: false, code: r.code, reason: r.reason };
if (!r.ok) return { ok: false, code: r.code, reason: r.reason };
return { ok: true, claims: r.claims };
return { ok: true, claims: r.claims };
}
}
@@ -55,9 +67,8 @@ function isNodeToken(token) {
@@ -55,9 +67,8 @@ function isNodeToken(token) {
* Verify a node token: signed by Network, audience openvibe.bot, actor_type node, sub node:nod_…
* Verify a node token: signed by Network, audience openvibe.bot, actor_type node, sub node:nod_…
* Returns { ok, principal: 'nod_…', claims } or { ok:false, code, reason }.
* Returns { ok, principal: 'nod_…', claims } or { ok:false, code, reason }.
*/
*/
-function verifyNode(token, { publicKey, issuer, audience }) {
- if (!publicKey) return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };
- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer, audience });
+async function verifyNode(token, opts) {
+ const r = await principalToken(token, opts);
if (!r.ok) return { ok: false, code: r.code, reason: r.reason };
if (!r.ok) return { ok: false, code: r.code, reason: r.reason };
const m = NODE_SUB.exec(String(r.claims.sub || ''));
const m = NODE_SUB.exec(String(r.claims.sub || ''));
if (r.claims.actor_type !== 'node' || !m) return { ok: false, code: 'token.invalid', reason: 'not a node token' };
if (r.claims.actor_type !== 'node' || !m) return { ok: false, code: 'token.invalid', reason: 'not a node token' };
@@ -65,24 +76,25 @@ function verifyNode(token, { publicKey, issuer, audience }) {
@@ -65,24 +76,25 @@ function verifyNode(token, { publicKey, issuer, audience }) {
}
}
function createApiAuth({ config, keys, userAuth }) {
function createApiAuth({ config, keys, userAuth }) {
- function resolve(req) {
+ const opts = () => ({ keyOptions: keys.verifyOptions, issuer: config.network.issuer, audience: config.audience });
+
+ async function resolve(req) {
const header = String(req.headers.authorization || '');
const header = String(req.headers.authorization || '');
if (!header.startsWith('Bearer ')) return { principal: ANON };
if (!header.startsWith('Bearer ')) return { principal: ANON };
const token = header.slice(7).trim();
const token = header.slice(7).trim();
- const publicKey = keys.get();
- if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
+ if (!keys.loaded()) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
const payload = decodePayload(token);
const payload = decodePayload(token);
if (payload && payload.actor_type === 'node') {
if (payload && payload.actor_type === 'node') {
- const r = verifyNode(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
+ const r = await verifyNode(token, opts());
if (!r.ok) return { error: [401, r.code, r.reason] };
if (!r.ok) return { error: [401, r.code, r.reason] };
return { principal: { kind: 'node', principal: r.principal, sub: r.claims.sub, jti: r.claims.jti } };
return { principal: { kind: 'node', principal: r.principal, sub: r.claims.sub, jti: r.claims.jti } };
}
}
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
- if (!r.ok) return { error: [401, r.code, r.reason] };
+ const r = await principalToken(token, opts());
+ if (!r.ok) return { error: [r.code === 'identity.unavailable' ? 503 : 401, r.code, r.reason] };
return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti } };
return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti } };
}
}
- const claims = userAuth.verify(token);
+ const claims = await userAuth.verify(token);
if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };
if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };
const p = userPrincipal(claims);
const p = userPrincipal(claims);
if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
@@ -90,10 +102,11 @@ function createApiAuth({ config, keys, userAuth }) {
@@ -90,10 +102,11 @@ function createApiAuth({ config, keys, userAuth }) {
}
}
function middleware(req, res, next) {
function middleware(req, res, next) {
- const r = resolve(req);
- if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
- req.principal = r.principal;
- return next();
+ resolve(req).then((r) => {
+ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
+ req.principal = r.principal;
+ return next();
+ }, next);
}
}
const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);
const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);
@@ -34,7 +34,7 @@ async function main() {
@@ -34,7 +34,7 @@ async function main() {
const app = createApp({ config, db, valkey, registry });
const app = createApp({ config, db, valkey, registry });
const { domain, keys, outbox, usage, hub, sim, onvif } = app.locals;
const { domain, keys, outbox, usage, hub, sim, onvif } = app.locals;
- keys.start();
+ keys.start().catch(() => { /* logged by the JWKS client */ });
const timers = [];
const timers = [];
if (config.jobs.enabled) {
if (config.jobs.enabled) {
@@ -3,49 +3,24 @@
@@ -3,49 +3,24 @@
/**
/**
* 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). It verifies service tokens
- * (audience openvibe.bot) and the browser's Network user JWT, both offline.
+ * 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). They verify service and node tokens (audience openvibe.bot) and the browser's Network
+ * user JWT, all offline (openvibe-sdk/auth).
* nodes Network's node principals (machines paired for Bot): POST /internal/node-pairings (a pairing
* nodes Network's node principals (machines paired for Bot): POST /internal/node-pairings (a pairing
* code, BOT_PAIRING_AUTHORITY=network), GET /internal/node-principals/:id and POST …/:id/revoke with
* code, BOT_PAIRING_AUTHORITY=network), GET /internal/node-principals/:id and POST …/:id/revoke with
* Bot's own svc:bot token (audience openvibe.network, network.node.manage).
* Bot's own svc:bot token (audience openvibe.network, network.node.manage).
* identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the
* identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the
* names of subjects the robot's owner added.
* names of subjects the robot's owner added.
*/
*/
-const crypto = require('crypto');
-const jwt = require('jsonwebtoken');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+const { createNetworkKeys, verifyUserToken } = require('openvibe-sdk/auth');
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { BotError } = require('./util');
const { BotError } = require('./util');
+/** 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');
- 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(`[Bot] 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 () => { await 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 });
}
}
/** The OAuth client of the browser session layer plus offline verification of the Network user JWT. */
/** The OAuth client of the browser session layer plus offline verification of the Network user JWT. */
@@ -58,19 +33,17 @@ function createUserAuth(config, keys) {
@@ -58,19 +33,17 @@ function createUserAuth(config, keys) {
authBase: config.network.url,
authBase: config.network.url,
internalBase: config.network.internalUrl,
internalBase: config.network.internalUrl,
});
});
- /** Claims of a valid user token, or null. Service tokens are never user tokens. */
- function verify(token) {
+ /**
+ * Claims of a valid user token, or null. openvibe-sdk/auth verifyUserToken refuses service, app and mod principals
+ * and typed tokens (a realtime ticket, a FedCM assertion, an export token); a node token (a machine paired for Bot)
+ * is refused here too.
+ */
+ async function verify(token) {
if (!token) return null;
if (!token) return null;
- const key = keys.get();
- if (!key) return null;
let claims;
let claims;
- try { claims = jwt.verify(token, key, { algorithms: ['RS256'], issuer: config.network.issuer }); } catch { return null; }
- if (!claims || typeof claims !== 'object') return null;
- if (typeof claims.sub === 'string' && /^(svc|app|mod|node):/.test(claims.sub)) return null;
- if (claims.actor_type === 'service' || claims.actor_type === 'node') return null;
- // A typed token is never a session: a realtime ticket (typ realtime) or a FedCM assertion (typ fedcm, issued to
- // another site) is signed by the same key, and this check takes no audience (openvibe-sdk/auth's rule).
- if (claims.typ !== undefined || claims.purpose !== undefined) return null;
+ try { claims = await verifyUserToken(String(token), { ...keys.verifyOptions, issuer: config.network.issuer }); } catch { return null; }
+ if (typeof claims.sub === 'string' && /^node:/.test(claims.sub)) return null;
+ if (claims.actor_type === 'node') return null;
return claims;
return claims;
}
}
return { client, verify };
return { client, verify };
@@ -35,7 +35,7 @@ function createBotReadiness({ db, valkey = null, keys, config, outbox, hub = nul
@@ -35,7 +35,7 @@ function createBotReadiness({ db, valkey = null, keys, config, outbox, hub = nul
checks: [
checks: [
{ name: 'db', required: true, check: () => db.ready() },
{ name: 'db', required: true, check: () => db.ready() },
{ name: 'valkey', required: false, check: () => (valkey ? valkey.ready() : skip('VALKEY_URL unset: per-actor limits are per process (one process only)')) },
{ name: 'valkey', required: false, check: () => (valkey ? valkey.ready() : skip('VALKEY_URL unset: per-actor limits are per process (one process only)')) },
- { name: 'network_jwks', required: false, check: () => (keys.get() ? true : 'Network signing key not loaded yet: tokens cannot be verified') },
+ { name: 'network_jwks', required: false, check: () => (keys.loaded() ? true : 'Network signing key not loaded yet: tokens cannot be verified') },
{ name: 'events', required: false, cacheMs: outbox.enabled ? PING_TTL_MS : 0, timeoutMs: 2500, check: events },
{ name: 'events', required: false, cacheMs: outbox.enabled ? PING_TTL_MS : 0, timeoutMs: 2500, check: events },
],
],
details: async (body) => (body.checks.db.status === 'ok'
details: async (body) => (body.checks.db.status === 'ok'
@@ -83,7 +83,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -83,7 +83,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
let path;
let path;
try { path = new URL(req.url, 'http://localhost').pathname; } catch { return socket.destroy(); }
try { path = new URL(req.url, 'http://localhost').pathname; } catch { return socket.destroy(); }
if (path === '/device') return deviceWss.handleUpgrade(req, socket, head, (ws) => onDeviceSocket(ws, req));
if (path === '/device') return deviceWss.handleUpgrade(req, socket, head, (ws) => onDeviceSocket(ws, req));
- if (path === '/control') return controlWss.handleUpgrade(req, socket, head, (ws) => onControlSocket(ws, req));
+ // Authorized before the upgrade is accepted: the socket's handlers exist from its first frame on.
+ if (path === '/control') {
+ return authorizeControl(req).then(
+ (principal) => controlWss.handleUpgrade(req, socket, head, (ws) => onControlSocket(ws, req, principal)),
+ (e) => { log.warn(`[Bot] control upgrade: ${e.message}`); socket.destroy(); });
+ }
if (path === '/watch') return watchWss.handleUpgrade(req, socket, head, (ws) => onWatchSocket(ws, req));
if (path === '/watch') return watchWss.handleUpgrade(req, socket, head, (ws) => onWatchSocket(ws, req));
socket.destroy();
socket.destroy();
}
}
@@ -157,6 +162,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -157,6 +162,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
conn.backlog = null;
conn.backlog = null;
}
}
+ /** What the openvibe-sdk/auth checks in api/auth.js take: Network's keys, issuer and Bot's audience. */
+ const tokenOpts = () => ({ keyOptions: keys.verifyOptions, issuer: config.network.issuer, audience: config.audience });
+
async function authenticateDevice(conn, credential) {
async function authenticateDevice(conn, credential) {
const device = await domain.devices.byCredential(credential);
const device = await domain.devices.byCredential(credential);
if (!device) throw new Error('unknown or revoked credential');
if (!device) throw new Error('unknown or revoked credential');
@@ -165,15 +173,15 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -165,15 +173,15 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
/** A node token: verified offline, then bound to its device (created on first use from Network's record). */
/** A node token: verified offline, then bound to its device (created on first use from Network's record). */
async function authenticateNode(conn, nodeToken) {
async function authenticateNode(conn, nodeToken) {
- const r = verifyNode(nodeToken, { publicKey: keys.get(), issuer: config.network.issuer, audience: config.audience });
+ const r = await verifyNode(nodeToken, tokenOpts());
if (!r.ok) throw new Error(`node token refused: ${r.code}`);
if (!r.ok) throw new Error(`node token refused: ${r.code}`);
const device = await domain.devices.bindNode(r.principal);
const device = await domain.devices.bindNode(r.principal);
conn.node = { principal: r.principal, reauthBy: now() + config.device.nodeReauthMs };
conn.node = { principal: r.principal, reauthBy: now() + config.device.nodeReauthMs };
attachDevice(conn, device);
attachDevice(conn, device);
}
}
/** `reauth`: a fresh node token for the same principal moves the deadline; anything else leaves it. */
/** `reauth`: a fresh node token for the same principal moves the deadline; anything else leaves it. */
- function onReauth(conn, msg) {
- const r = verifyNode(typeof msg.token === 'string' ? msg.token : '', { publicKey: keys.get(), issuer: config.network.issuer, audience: config.audience });
+ async function onReauth(conn, msg) {
+ const r = await verifyNode(typeof msg.token === 'string' ? msg.token : '', tokenOpts());
if (!r.ok) return sendError(conn, 'bot.reauth_refused', r.reason);
if (!r.ok) return sendError(conn, 'bot.reauth_refused', r.reason);
if (r.principal !== conn.node.principal) return sendError(conn, 'bot.reauth_refused', 'the token is for another machine');
if (r.principal !== conn.node.principal) return sendError(conn, 'bot.reauth_refused', 'the token is for another machine');
conn.node.reauthBy = now() + config.device.nodeReauthMs;
conn.node.reauthBy = now() + config.device.nodeReauthMs;
@@ -364,25 +372,24 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -364,25 +372,24 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
}
// ── Operator side ─────────────────────────────────────────────────────────────────────────────
// ── Operator side ─────────────────────────────────────────────────────────────────────────────
- function authorizeControl(req) {
+ async function authorizeControl(req) {
const cookie = parseCookies(req.headers.cookie)[ACCESS_COOKIE];
const cookie = parseCookies(req.headers.cookie)[ACCESS_COOKIE];
- if (cookie) { const claims = userAuth.verify(cookie); const p = userPrincipal(claims); if (p) return p; }
+ if (cookie) { const claims = await userAuth.verify(cookie); const p = userPrincipal(claims); if (p) return p; }
const token = bearer(req);
const token = bearer(req);
if (!token) return null;
if (!token) return null;
const payload = decodePayload(token);
const payload = decodePayload(token);
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
- const r = verifyService(token, { publicKey: keys.get(), issuer: config.network.issuer, audience: config.audience });
+ const r = await verifyService(token, tokenOpts());
if (!r.ok || !capabilities.grants(r.claims.cap || [], 'bot.robot.control')) return null;
if (!r.ok || !capabilities.grants(r.claims.cap || [], 'bot.robot.control')) return null;
const subject = String(req.headers['x-ov-subject'] || '');
const subject = String(req.headers['x-ov-subject'] || '');
if (!isSubject(subject)) return null;
if (!isSubject(subject)) return null;
return { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], subject };
return { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], subject };
}
}
- const claims = userAuth.verify(token);
+ const claims = await userAuth.verify(token);
return userPrincipal(claims);
return userPrincipal(claims);
}
}
- function onControlSocket(ws, req) {
- const principal = authorizeControl(req);
+ function onControlSocket(ws, req, principal) {
if (!principal) return ws.close(4002, 'sign in required');
if (!principal) return ws.close(4002, 'sign in required');
const conn = { ws, seq: 0, principal, subject: principal.subject, robotId: null, role: null };
const conn = { ws, seq: 0, principal, subject: principal.subject, robotId: null, role: null };
ws.conn = conn;
ws.conn = conn;
@@ -60,10 +60,11 @@ function withParam(target, key, value) {
@@ -60,10 +60,11 @@ function withParam(target, key, value) {
function viewerMiddleware(userAuth) {
function viewerMiddleware(userAuth) {
return (req, _res, next) => {
return (req, _res, next) => {
const token = req.cookies && req.cookies[ACCESS_COOKIE];
const token = req.cookies && req.cookies[ACCESS_COOKIE];
- const claims = token ? userAuth.verify(token) : null;
- req.viewer = claims ? userPrincipal(claims) : null;
- req.viewerClaims = claims ? claimsToUser(claims) : null;
- next();
+ Promise.resolve(token ? userAuth.verify(token) : null).then((claims) => {
+ req.viewer = claims ? userPrincipal(claims) : null;
+ req.viewerClaims = claims ? claimsToUser(claims) : null;
+ next();
+ }, next);
};
};
}
}
@@ -111,9 +112,9 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -111,9 +112,9 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
throw lastErr || new Error('Network unreachable');
throw lastErr || new Error('Network unreachable');
}
}
- router.get('/login', (req, res) => {
+ router.get('/login', async (req, res) => {
const silent = !!req.query.silent && req.query.silent !== '0';
const silent = !!req.query.silent && req.query.silent !== '0';
- if (silent && userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }
+ if (silent && await userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }
const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);
const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);
let target = url;
let target = url;
if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }
if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }
@@ -162,7 +163,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -162,7 +163,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
setSession(res, data.access_token, data.refresh_token);
setSession(res, data.access_token, data.refresh_token);
- return res.json({ ok: true, user: data.user || claimsToUser(userAuth.verify(data.access_token)) });
+ return res.json({ ok: true, user: data.user || claimsToUser(await userAuth.verify(data.access_token)) });
} catch (err) {
} catch (err) {
if (err.status && err.status < 500) return res.status(401).json({ error: err.error || 'invalid_grant' });
if (err.status && err.status < 500) return res.status(401).json({ error: err.error || 'invalid_grant' });
return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
@@ -185,9 +186,9 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -185,9 +186,9 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
res.redirect(sanitizeNext(req.query.next, config));
res.redirect(sanitizeNext(req.query.next, config));
});
});
- router.get('/me', (req, res) => {
+ router.get('/me', async (req, res) => {
const token = (String(req.headers.authorization || '').startsWith('Bearer ') ? req.headers.authorization.slice(7) : null) || (req.cookies && req.cookies[ACCESS_COOKIE]);
const token = (String(req.headers.authorization || '').startsWith('Bearer ') ? req.headers.authorization.slice(7) : null) || (req.cookies && req.cookies[ACCESS_COOKIE]);
- const claims = userAuth.verify(token);
+ const claims = await userAuth.verify(token);
if (!claims) return res.status(401).json({ error: 'Not authenticated' });
if (!claims) return res.status(401).json({ error: 'Not authenticated' });
res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
});
});
@@ -198,7 +199,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -198,7 +199,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
try {
try {
const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
setSession(res, data.access_token, data.refresh_token);
setSession(res, data.access_token, data.refresh_token);
- return res.json({ token: data.access_token, user: claimsToUser(userAuth.verify(data.access_token)) });
+ return res.json({ token: data.access_token, user: claimsToUser(await userAuth.verify(data.access_token)) });
} catch (err) {
} catch (err) {
if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }
if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }
return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
@@ -37,9 +37,9 @@ const { boot, check, done } = require('./helpers/app');
@@ -37,9 +37,9 @@ const { boot, check, done } = require('./helpers/app');
await check('a typed Network token (realtime ticket, FedCM assertion) is never a session, even signed by the Network key', async () => {
await check('a typed Network token (realtime ticket, FedCM assertion) is never a session, even signed by the Network key', async () => {
assert.strictEqual((await t.call('GET', '/api/v1/robots', { user: alex })).status, 200, 'a session token works');
assert.strictEqual((await t.call('GET', '/api/v1/robots', { user: alex })).status, 200, 'a session token works');
- for (const extra of [{ typ: 'realtime', purpose: 'realtime' }, { typ: 'fedcm', aud: 'https://elsewhere.example' }]) {
+ for (const extra of [{ typ: 'realtime', purpose: 'realtime' }, { typ: 'fedcm', aud: 'https://elsewhere.example' }, { purpose: 'export' }]) {
const r = await t.call('GET', '/api/v1/robots', { token: t.network.signUser(alex, extra) });
const r = await t.call('GET', '/api/v1/robots', { token: t.network.signUser(alex, extra) });
- assert.strictEqual(r.status, 401, `${extra.typ}: ${r.status}`);
+ assert.strictEqual(r.status, 401, `${extra.typ || extra.purpose}: ${r.status}`);
}
}
});
});
@@ -274,6 +274,15 @@ const plugins = require('./helpers/plugins');
@@ -274,6 +274,15 @@ const plugins = require('./helpers/plugins');
assert.strictEqual(await weak.waitForClose(), 4002, 'without bot.robot.control a service cannot control');
assert.strictEqual(await weak.waitForClose(), 4002, 'without bot.robot.control a service cannot control');
const anon = await t.ws('/control');
const anon = await t.ws('/control');
assert.strictEqual(await anon.waitForClose(), 4002, 'an anonymous socket is refused');
assert.strictEqual(await anon.waitForClose(), 4002, 'an anonymous socket is refused');
+ // The socket is authorized before its upgrade is accepted (openvibe-sdk/auth checks are async): a typed Network
+ // token is never a session there, as the cookie or as a Bearer.
+ for (const extra of [{ typ: 'realtime', purpose: 'realtime' }, { typ: 'fedcm' }, { purpose: 'export' }]) {
+ const typed = t.network.signUser(bob, extra);
+ const viaCookie = await t.ws('/control', { headers: { Cookie: `ov_token=${typed}` } });
+ assert.strictEqual(await viaCookie.waitForClose(), 4002, `${JSON.stringify(extra)} as the cookie`);
+ const viaBearer = await t.ws('/control', { headers: { Authorization: `Bearer ${typed}` } });
+ assert.strictEqual(await viaBearer.waitForClose(), 4002, `${JSON.stringify(extra)} as a Bearer`);
+ }
});
});
await check('a service token forwards a command over HTTP and the device\'s ack comes back', async () => {
await check('a service token forwards a command over HTTP and the device\'s ack comes back', async () => {
@@ -58,7 +58,7 @@ async function boot(opts = {}) {
@@ -58,7 +58,7 @@ async function boot(opts = {}) {
const store = opts.db ? { db: opts.db, store: opts.db.store, close: async () => {} } : await testDb({ store: opts.store });
const store = opts.db ? { db: opts.db, store: opts.db.store, close: async () => {} } : await testDb({ store: opts.store });
await seedProfiles(store.db, { log: { log() {} } });
await seedProfiles(store.db, { log: { log() {} } });
const app = createApp({ config, db: store.db, valkey: opts.valkey || null, now: () => Date.now() + clock.offset, log, ...(opts.appOpts || {}) });
const app = createApp({ config, db: store.db, valkey: opts.valkey || null, now: () => Date.now() + clock.offset, log, ...(opts.appOpts || {}) });
- await app.locals.keys.load();
+ await app.locals.keys.refresh();
const server = await new Promise((resolve) => {
const server = await new Promise((resolve) => {
const s = http.createServer(app);
const s = http.createServer(app);
s.on('upgrade', (req, socket, head) => app.locals.hub.handleUpgrade(req, socket, head));
s.on('upgrade', (req, socket, head) => app.locals.hub.handleUpgrade(req, socket, head));