openvibers/openvibe.chat Commits

Chat: the Network keys and token checks come from openvibe-sdk/auth (#61)

OP OpenVibers committed 10 Oct 2026 · 1394f96152 · 8 files, +70 −70

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

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

* Chat: the Network keys and token checks come from openvibe-sdk/auth

Plan T1: delete hand-rolled copies. Chat verified Network session tokens with its own code and loaded the key with its own loader, which read only the legacy `public_key` field of the JWKS. Both now come from openvibe-sdk/auth (v0.40.0; this PR carries the pin bump from #60).

- **Keys:** `server/net/service-auth.js` `keys()` is a `createNetworkKeys` instance, created on first use so nothing is fetched at module load. It uses the PEM file `OV_NETWORK_PUBLIC_KEY` names when it is readable; otherwise Network's JWKS (`keys[]`, with `public_key` as the fallback), with a rotation honoured on an unknown kid and the last good keys kept through an outage. `ensureKey` and `setPublicKey` are deleted.
- **Service tokens:** the `/internal/*` guard uses `verifyServiceToken` with Chat's pinned contracts. No key still answers 503 `identity.unavailable`.
- **Session tokens:** `server/auth/network-session.js` `verify()` is `verifyUserToken` (no audience, as before). It refuses service principals and typed tokens: realtime tickets, FedCM assertions and `purpose` tokens (Chat already refused `typ` and `actor_type`). The SDK's refusal codes map to Chat's reasons. A bad signature, a non-JWT or no key yet still goes to Live; anything else is refused here.
- **One behaviour change:** expiry now allows the 30 s clock skew every OpenVibe verifier uses; Chat had allowed none. The tests use a token 60 s past expiry.
- **Test fix:** `test/message-deleted.test.js` still called `relay.flush()`, which Chat#59 replaced with `relay.outbox.flush()`. CI skips that e2e test because it needs an Events checkout next to Chat, so the break went unseen. It passes now.

Tests:
- `test/network-session.test.js` adds a realtime ticket and a FedCM assertion to the tokens refused here.
- It checks that the token itself verifies with `{ publicKey }`.
- It still checks `alg: none` and HS256.
- `ov test` covering both files, plus the internal-route, calls, WebSocket and web suites, is green.

Changes

ddiff --git a/README.md b/README.md
iindex 317a2db..ad01884 100644
---- a/README.md
++++ b/README.md
@@@ -575,6 +575,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.129.0
--- openvibe-sdk: v0.38.1
++- openvibe-sdk: v0.40.0
- openvibe-shared: v3.0.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 9c73ea6..b7b53cd 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"port": 4400,
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"contracts": "openvibe-contracts v0.129.0",
-- "sdk": "openvibe-sdk v0.38.1",
++ "sdk": "openvibe-sdk v0.40.0",
"shared": "openvibe-shared v3.0.0",
"capabilities": [
"chat.message.send",
ddiff --git a/package-lock.json b/package-lock.json
iindex 5c021b7..dd76266 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"multer": "^2.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.1",
++ "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"
@@@ -1036,9 +1036,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.38.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.1",
-- "integrity": "sha512-Pn3nji9Nc+4rMRjhwO9AhhR80OW7cwLo/NLObiVR/4TlcOxyk/V8DLzGHQmFjNxqg0xLBj6MOiXYQR2qAxEycg==",
++ "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 b86dfae..3582bcb 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"iovalkey": "^0.4.0",
"multer": "^2.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.1",
++ "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"
ddiff --git a/server/auth/network-session.js b/server/auth/network-session.js
iindex e24d1d1..0ac5dfe 100644
---- a/server/auth/network-session.js
++++ b/server/auth/network-session.js
@@@ -1,13 +1,14 @@
/**
* OpenVibe.Chat — Network session tokens verified here (WS-I task 3).
*
-- * A browser's Network JWT (RS256, issuer the Network) is verified with the Network's public key
-- * (net/service-auth.js ensureKey: the PEM file, else the Network's JWKS) and resolved to the chat
++ * A browser's Network JWT (RS256, issuer the Network) is verified with openvibe-sdk/auth verifyUserToken
++ * against Network's keys (net/service-auth.js keys(): the PEM file, else the Network's JWKS, with a
++ * rotation honoured on an unknown kid) and resolved to the chat
* user through the ctx_users projection by its subject (usr_…). Live is asked (live-context
* authenticate, which also creates first-time accounts) only when that cannot answer:
* - hbt_ API tokens (Live owns them and their scopes);
-- * - no key loaded yet, anything that is not a JWT, or a signature that does not match the key we
-- * hold (a rotation);
++ * - no key loaded yet, anything that is not a JWT, or a signature that does not match any key
++ * Network publishes;
* - a token without a subject, or a subject with no single projected user (first visit, merges).
* So a signed-in person keeps chatting while Live restarts or is down.
*
@@@ -19,6 +20,7 @@
const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
++const { verifyUserToken } = require('openvibe-sdk/auth');
const config = require('../config');
const db = require('../db/database');
const serviceAuth = require('../net/service-auth');
@@@ -32,22 +34,24 @@ const tokenKey = (t) => crypto.createHash('sha256').update(String(t)).digest('he
const b64json = (s) => JSON.parse(Buffer.from(s, 'base64url').toString('utf8'));
--/** { ok: true, claims } | { ok: false, reason }. Session tokens only (no typ / actor_type claims). */
--function verify(token, publicKey, { issuer = config.networkUrl, now = Date.now() } = {}) {
-- const parts = String(token || '').split('.');
-- if (parts.length !== 3) return { ok: false, reason: 'malformed' };
-- let header; let claims;
-- try { header = b64json(parts[0]); claims = b64json(parts[1]); } catch { return { ok: false, reason: 'malformed' }; }
-- if (!header || header.alg !== 'RS256' || !claims || typeof claims !== 'object') return { ok: false, reason: 'malformed' };
-- let good = false;
-- try { good = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url')); } catch { good = false; }
-- if (!good) return { ok: false, reason: 'signature' };
-- const s = Math.floor(now / 1000);
-- if (typeof claims.exp !== 'number' || s >= claims.exp) return { ok: false, reason: 'expired' };
-- if (typeof claims.nbf === 'number' && s + 30 < claims.nbf) return { ok: false, reason: 'not_yet_valid' };
-- if (issuer && claims.iss !== issuer) return { ok: false, reason: 'issuer' };
-- if (claims.typ !== undefined || claims.actor_type !== undefined) return { ok: false, reason: 'not_a_session_token' };
-- return { ok: true, claims };
++// openvibe-sdk/auth's refusal codes, in the words authenticate() decides on.
++const REASONS = {
++ 'token.malformed': 'malformed', 'token.bad_signature': 'signature', 'token.no_key': 'no_key',
++ 'token.expired': 'expired', 'token.not_yet_valid': 'not_yet_valid', 'token.wrong_issuer': 'issuer',
++ 'token.not_user': 'not_a_session_token',
++};
++
++/**
++ * { ok: true, claims } | { ok: false, reason }. Session tokens only: openvibe-sdk/auth verifyUserToken refuses service
++ * principals and typed tokens (a realtime ticket, a FedCM assertion). keyOptions: Network's keys by default; a test
++ * passes { publicKey }.
++ */
++async function verify(token, keyOptions = serviceAuth.keys().verifyOptions, { issuer = config.networkUrl } = {}) {
++ try {
++ return { ok: true, claims: await verifyUserToken(String(token || ''), { ...keyOptions, issuer }) };
++ } catch (err) {
++ return { ok: false, reason: REASONS[err && err.code] || 'invalid' };
++ }
}
/** The projected user for a verified token, or null when the projection cannot say which one. */
@@@ -86,11 +90,10 @@ async function authenticate(token) {
return u;
};
if (String(token).startsWith('hbt_')) return await viaLive();
-- const key = await serviceAuth.ensureKey();
-- if (!key) return await viaLive();
-- const r = verify(token, key);
-- // Only a token signed by the key we hold is decided here; anything else is Live's to judge.
-- if (!r.ok) return r.reason === 'signature' || r.reason === 'malformed' ? await viaLive() : refuse(token);
++ const r = await verify(token);
++ // Only a token signed by a key Network publishes is decided here; anything else (no key yet, not a JWT, another
++ // signer) is Live's to judge.
++ if (!r.ok) return ['signature', 'malformed', 'no_key'].includes(r.reason) ? await viaLive() : refuse(token);
// Signed out everywhere, password changed, banned…: Network's cutoff for this person (WS-B task 4).
if (await revocations.isRevoked(r.claims)) return refuse(token);
const user = await userFor(r.claims);
ddiff --git a/server/net/service-auth.js b/server/net/service-auth.js
iindex 9057113..b7930bb 100644
---- a/server/net/service-auth.js
++++ b/server/net/service-auth.js
@@@ -4,8 +4,14 @@
* headers(audience, scope) Chat → another service: cached client-credentials token from the
* Network's /oauth/token for client `chat`.
* guard(capability) another service → Chat: Express guard for /internal/* that
-- * verifies an RS256 service token for audience openvibe.chat and
-- * checks the capability in its `cap` claim.
++ * verifies an RS256 service token for audience openvibe.chat
++ * (openvibe-sdk/auth verifyServiceToken: the pinned contracts rules, the
++ * key the token's kid names) and checks the capability in its `cap` claim.
++ * keys() Network's signing keys (openvibe-sdk/auth createNetworkKeys), read by this
++ * guard and by auth/network-session.js: the PEM file OV_NETWORK_PUBLIC_KEY
++ * names when it is readable, else Network's JWKS (a rotation honoured on an
++ * unknown kid, the last good keys kept through an outage). Created on first
++ * use, so nothing is fetched at module load.
*
* The capabilities Chat introduces (docs/capabilities-proposal/) are not in the contracts
* registry yet, so requireCapability() — which refuses unknown ids — can't be used for them;
@@@ -16,13 +22,14 @@
'use strict';
const fs = require('fs');
--const { serviceAuth, capabilities, http } = require('openvibe-contracts');
++const contracts = require('openvibe-contracts');
++const { createNetworkKeys, verifyServiceToken } = require('openvibe-sdk/auth');
const config = require('../config');
++const { serviceAuth, capabilities, http } = contracts;
const AUDIENCE = 'openvibe.chat';
--let _publicKey = null;
--let _keyLoading = null;
++let _keys = null;
function loadKeyFromFile() {
const p = config.networkPublicKeyPath;
@@@ -30,26 +37,11 @@ function loadKeyFromFile() {
try { return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null; } catch { return null; }
}
--/** The Network's RS256 public key: the PEM file when configured, else the Network's JWKS endpoint. */
--async function ensureKey() {
-- if (_publicKey) return _publicKey;
-- const fromFile = loadKeyFromFile();
-- if (fromFile) { _publicKey = fromFile; return _publicKey; }
-- if (!_keyLoading) {
-- _keyLoading = (async () => {
-- try {
-- const res = await fetch(`${config.networkInternalUrl}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
-- const jwks = await res.json().catch(() => null);
-- if (jwks && jwks.public_key) { _publicKey = jwks.public_key; console.log('[Auth] Network public key loaded from JWKS'); }
-- } catch (err) {
-- console.warn('[Auth] Network public key unavailable:', err.message);
-- } finally { _keyLoading = null; }
-- return _publicKey;
-- })();
-- }
-- return _keyLoading;
++/** Network's signing keys: the PEM file when configured and readable, else Network's JWKS. */
++function keys() {
++ if (!_keys) _keys = createNetworkKeys({ network: config.networkInternalUrl, publicKey: loadKeyFromFile(), log: console });
++ return _keys;
}
--function setPublicKey(pem) { _publicKey = pem || null; }
const _clients = new Map();
function clientFor(audience, scope) {
@@@ -82,14 +74,16 @@ function viaProxy(req) {
/** Express guard for one capability on an /internal route. */
function guard(capability) {
const registered = !!capabilities.get(capability);
-- return async function serviceGuard(req, res, next) {
++ return function serviceGuard(req, res, next) {
++ check(req, res, next).catch(next);
++ };
++ async function check(req, res, next) {
const ctx = http.requestContext(req.headers);
if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });
const auth = String(req.headers.authorization || '');
if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token', ctx });
-- const publicKey = await ensureKey();
-- if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded', ctx });
-- const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
++ const r = await verifyServiceToken(auth.slice(7).trim(), { ...keys().verifyOptions, issuer: config.networkUrl, audience: AUDIENCE, contracts });
++ if (!r.ok && r.code === 'token.unavailable') return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded', ctx });
if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx });
const c = registered
? capabilities.check(r.claims, capability)
@@@ -97,7 +91,7 @@ function guard(capability) {
if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx });
req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
next();
-- };
++ }
}
--module.exports = { AUDIENCE, ensureKey, setPublicKey, headers, invalidate, guard, viaProxy };
++module.exports = { AUDIENCE, keys, headers, invalidate, guard, viaProxy };
ddiff --git a/test/message-deleted.test.js b/test/message-deleted.test.js
iindex 2b1c63f..7f9ef8a 100644
---- a/test/message-deleted.test.js
++++ b/test/message-deleted.test.js
@@@ -209,7 +209,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
if (!haveEvents) return;
await h.db.run('UPDATE service_outbox SET sent_at = ? WHERE sent_at IS NULL', [Date.now()]); // earlier tests' rows
msgId = await say(SECRET, { user_id: alice.id, anon_id: null, username: 'alice' });
-- assert.strictEqual((await h.eventsRelay.flush()).sent, 1);
++ assert.strictEqual((await h.eventsRelay.outbox.flush()).sent, 1);
firstSeq = await events.store.lastSeq();
const before = await sse(`/realtime/stream?topics=chat.message.*&last_event_id=${firstSeq - 1}`);
await before.waitFor((c) => c.events().length === 1);
@@@ -217,7 +217,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
before.close();
await h.db.deleteChatMessage(msgId, admin.id);
-- assert.strictEqual((await h.eventsRelay.flush()).sent, 1);
++ assert.strictEqual((await h.eventsRelay.outbox.flush()).sent, 1);
const views = [
['anonymous', {}],
['signed in (cookie)', { Cookie: `ov_token=${userJwt('usr_01J9AAAAAAAAAAAAAAAAAAAAAA')}` }],
@@@ -246,7 +246,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
t('e2e: only svc:chat can redact Chat’s events', async () => {
if (!haveEvents) return;
const id = await say('not yours to delete');
-- await h.eventsRelay.flush();
++ await h.eventsRelay.outbox.flush();
const created = (await events.store.scan(0, { patterns: ['chat.message.created'], limit: 1000 })).rows.find((r) => r.subject_id === String(id));
const env = (await deletions(0)).at(-1); // a well-formed deletion, re-sourced by another service
const post = (sub, body) => fetch(`http://127.0.0.1:${eventsPort}/api/v1/events`, {
@@@ -260,7 +260,7 @@ t('e2e: only svc:chat can redact Chat’s events', async () => {
assert.strictEqual(r.status, 403, 'svc:live cannot publish as chat');
assert.ok((await events.store.getEvent(created.id)).payload.includes('not yours to delete'), 'still intact');
await h.db.deleteChatMessage(id, admin.id);
-- await h.eventsRelay.flush();
++ await h.eventsRelay.outbox.flush();
assert.ok((await events.store.getEvent(created.id)).redacted_at, 'Chat itself can');
});
ddiff --git a/test/network-session.test.js b/test/network-session.test.js
iindex c8ea529..e76caa7 100644
---- a/test/network-session.test.js
++++ b/test/network-session.test.js
@@@ -48,10 +48,12 @@ t('staff_caps come from the token; its role only raises the projection\'s', asyn
t('expired, wrong-issuer, not-yet-valid and service tokens are refused here', async () => {
const before = h.live.requests.filter((p) => p.endsWith('/auth')).length;
for (const bad of [
-- claimsFor(alice, { exp: now() - 5 }),
++ claimsFor(alice, { exp: now() - 60 }), // past the 30 s clock skew every OpenVibe verifier allows
claimsFor(alice, { iss: 'https://evil.example' }),
claimsFor(alice, { nbf: now() + 600 }),
claimsFor(alice, { typ: 'service' }),
++ claimsFor(alice, { typ: 'realtime', purpose: 'realtime' }),
++ claimsFor(alice, { typ: 'fedcm' }),
claimsFor(alice, { actor_type: 'service' }),
(() => { const c = claimsFor(alice); delete c.exp; return c; })(),
]) {
@@@ -59,8 +61,9 @@ t('expired, wrong-issuer, not-yet-valid and service tokens are refused here', as
assert.strictEqual(await session.authenticate(tok), null, JSON.stringify(bad));
assert.strictEqual(session.failureReason(tok), 'invalid');
}
-- assert.strictEqual(session.verify(jwt(claimsFor(alice), h.keys.privateKey, { alg: 'none' }), h.keys.publicKey).ok, false, 'alg none');
-- assert.strictEqual(session.verify(jwt(claimsFor(alice), h.keys.privateKey, { alg: 'HS256' }), h.keys.publicKey).ok, false, 'RS256 only');
++ assert.strictEqual((await session.verify(jwt(claimsFor(alice), h.keys.privateKey, { alg: 'none' }), { publicKey: h.keys.publicKey })).ok, false, 'alg none');
++ assert.strictEqual((await session.verify(jwt(claimsFor(alice), h.keys.privateKey, { alg: 'HS256' }), { publicKey: h.keys.publicKey })).ok, false, 'RS256 only');
++ assert.strictEqual((await session.verify(jwt(claimsFor(alice)), { publicKey: h.keys.publicKey })).ok, true, 'and the token itself verifies');
assert.strictEqual(h.live.requests.filter((p) => p.endsWith('/auth')).length, before, 'no Live call for a token we can judge');
});
@@@ -91,7 +94,7 @@ t('REST and the WebSocket keep working for a Network token while Live is down',
const auth = await ws.next((m) => m.type === 'auth');
assert.deepStrictEqual([auth.authenticated, auth.core_username, auth.user_id], [true, 'alice', alice.id]);
ws.close();
-- assert.strictEqual((await h.http('GET', '/api/chat/search?q=hello', { token: jwt(claimsFor(alice, { exp: now() - 1 })) })).status, 401);
++ assert.strictEqual((await h.http('GET', '/api/chat/search?q=hello', { token: jwt(claimsFor(alice, { exp: now() - 60 })) })).status, 401);
} finally { h.live.down = false; }
});