openvibers/openvibe.bot Commits

Bot: Network keys and token checks from openvibe-sdk/auth (async, control socket authorized before upgrade) (#71)

OP OpenVibers committed 10 Oct 2026 · 7fba2e9cb9 · 13 files, +102 −99

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

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

* Bot: Network keys and token checks from openvibe-sdk/auth (async, control socket authorized before upgrade)

Plan T1: delete hand-rolled copies. Bot had its own Network key store (one PEM, no kid, a 6 h refresh) and checked user tokens with `jsonwebtoken`. Both now go through openvibe-sdk/auth (v0.40.0; this PR carries the pin bump from #70).

- **Keys:** `createKeyProvider` returns `createNetworkKeys`. A pinned `OV_NETWORK_PUBLIC_KEY` is used as is. Otherwise the JWKS is retried every 30 s until it loads, refreshed every 15 minutes, and a rotation is followed on an unknown kid. The `network_jwks` readiness check reads `keys.loaded()`.
- **User tokens:** `verifyUserToken` refuses service, app and mod principals and typed tokens (realtime tickets, FedCM assertions, export tokens). Bot still refuses node tokens itself, since the SDK does not know node principals.
- **Service and node tokens:** `verifyServiceToken` with Bot's pinned contracts. No key still answers `identity.unavailable` (503).
- **Async:** every check is async now.
  - The `/control` socket is authorized before its upgrade is accepted, so its message handlers exist from the first frame and nothing can arrive mid-check. An unauthorized socket still closes with 4002.
  - The node handshake and `reauth` await their checks.
  - The session routes and the page viewer middleware await theirs, with errors going to `next`.
- `jsonwebtoken` moves to devDependencies; only the test stubs sign with it.

Tests:
- `test/control.test.js`: a realtime ticket, a FedCM assertion and an export token are each refused on `/control` (close 4002), both as the `ov_token` cookie and as a Bearer.
- `test/api.test.js`: an export-purpose token joins the typed tokens refused on the API.
- `ov test` (30 files) is green.

Changes

ddiff --git a/README.md b/README.md
iindex c897213..baa7e7f 100644
---- a/README.md
++++ b/README.md
@@@ -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 two
subscriptions 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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 410b156..04f5466 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/package-lock.json b/package-lock.json
iindex d9c9bf7..61e3652 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 7b0fec9..e68e8da 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/server/api/auth.js b/server/api/auth.js
iindex 200b5b4..3fead9c 100644
---- a/server/api/auth.js
++++ b/server/api/auth.js
@@@ -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);
ddiff --git a/server/index.js b/server/index.js
iindex 735d1d7..09f0bdf 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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) {
ddiff --git a/server/network.js b/server/network.js
iindex 09a29b8..ff87cd6 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -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 };
ddiff --git a/server/observability.js b/server/observability.js
iindex 1196666..7b8b51c 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -35,7 +35,7 @@ function createBotReadiness({ db, valkey = null, keys, config, outbox, hub = nul
checks: [
{ 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'
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 1c35a2e..698a8dd 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -83,7 +83,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
let 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 = () => Dat
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) {
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;
ddiff --git a/server/web/session.js b/server/web/session.js
iindex 4e0a193..6bfc039 100644
---- a/server/web/session.js
++++ b/server/web/session.js
@@@ -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' });
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 726c196..094a9ac 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -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}`);
}
});
ddiff --git a/test/control.test.js b/test/control.test.js
iindex 05636e2..08b9f6a 100644
---- a/test/control.test.js
++++ b/test/control.test.js
@@@ -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 () => {
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex c93b9e3..9d34bea 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -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));