openvibers/openvibe.chat Commits
Chat: the Network keys and token checks come from openvibe-sdk/auth (#61)
Changes
@@@ -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 -->@@@ -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",@@@ -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"@@@ -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"@@@ -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('heconst 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);@@@ -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 };@@@ -209,7 +209,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only tif (!haveEvents) return;await h.db.run('UPDATE service_outbox SET sent_at = ? WHERE sent_at IS NULL', [Date.now()]); // earlier tests' rowsmsgId = 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 tbefore.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 tt('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 serviceconst 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');});@@@ -48,10 +48,12 @@ t('staff_caps come from the token; its role only raises the projection\'s', asynt('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 allowsclaimsFor(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', asassert.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; }});
@@ -575,6 +575,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -575,6 +575,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.129.0
- openvibe-contracts: v0.129.0
-- openvibe-sdk: v0.38.1
+- openvibe-sdk: v0.40.0
- openvibe-shared: v3.0.0
- openvibe-shared: v3.0.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"port": 4400,
"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)",
"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",
"contracts": "openvibe-contracts v0.129.0",
- "sdk": "openvibe-sdk v0.38.1",
+ "sdk": "openvibe-sdk v0.40.0",
"shared": "openvibe-shared v3.0.0",
"shared": "openvibe-shared v3.0.0",
"capabilities": [
"capabilities": [
"chat.message.send",
"chat.message.send",
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.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.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",
"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"
@@ -1036,9 +1036,9 @@
@@ -1036,9 +1036,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.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.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",
"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"
@@ -1,13 +1,14 @@
@@ -1,13 +1,14 @@
/**
/**
* OpenVibe.Chat — Network session tokens verified here (WS-I task 3).
* 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
* 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:
* authenticate, which also creates first-time accounts) only when that cannot answer:
* - hbt_ API tokens (Live owns them and their scopes);
* - 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).
* - 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.
* So a signed-in person keeps chatting while Live restarts or is down.
*
*
@@ -19,6 +20,7 @@
@@ -19,6 +20,7 @@
const crypto = require('crypto');
const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
+const { verifyUserToken } = require('openvibe-sdk/auth');
const config = require('../config');
const config = require('../config');
const db = require('../db/database');
const db = require('../db/database');
const serviceAuth = require('../net/service-auth');
const serviceAuth = require('../net/service-auth');
@@ -32,22 +34,24 @@ const tokenKey = (t) => crypto.createHash('sha256').update(String(t)).digest('he
@@ -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'));
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. */
/** The projected user for a verified token, or null when the projection cannot say which one. */
@@ -86,11 +90,10 @@ async function authenticate(token) {
@@ -86,11 +90,10 @@ async function authenticate(token) {
return u;
return u;
};
};
if (String(token).startsWith('hbt_')) return await viaLive();
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).
// 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);
if (await revocations.isRevoked(r.claims)) return refuse(token);
const user = await userFor(r.claims);
const user = await userFor(r.claims);
@@ -4,8 +4,14 @@
@@ -4,8 +4,14 @@
* headers(audience, scope) Chat → another service: cached client-credentials token from the
* headers(audience, scope) Chat → another service: cached client-credentials token from the
* Network's /oauth/token for client `chat`.
* Network's /oauth/token for client `chat`.
* guard(capability) another service → Chat: Express guard for /internal/* that
* 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
* 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;
* registry yet, so requireCapability() — which refuses unknown ids — can't be used for them;
@@ -16,13 +22,14 @@
@@ -16,13 +22,14 @@
'use strict';
'use strict';
const fs = require('fs');
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 config = require('../config');
+const { serviceAuth, capabilities, http } = contracts;
const AUDIENCE = 'openvibe.chat';
const AUDIENCE = 'openvibe.chat';
-let _publicKey = null;
-let _keyLoading = null;
+let _keys = null;
function loadKeyFromFile() {
function loadKeyFromFile() {
const p = config.networkPublicKeyPath;
const p = config.networkPublicKeyPath;
@@ -30,26 +37,11 @@ function loadKeyFromFile() {
@@ -30,26 +37,11 @@ function loadKeyFromFile() {
try { return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null; } catch { return null; }
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();
const _clients = new Map();
function clientFor(audience, scope) {
function clientFor(audience, scope) {
@@ -82,14 +74,16 @@ function viaProxy(req) {
@@ -82,14 +74,16 @@ function viaProxy(req) {
/** Express guard for one capability on an /internal route. */
/** Express guard for one capability on an /internal route. */
function guard(capability) {
function guard(capability) {
const registered = !!capabilities.get(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);
const ctx = http.requestContext(req.headers);
if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });
if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });
const auth = String(req.headers.authorization || '');
const auth = String(req.headers.authorization || '');
if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token', ctx });
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 });
if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx });
const c = registered
const c = registered
? capabilities.check(r.claims, capability)
? capabilities.check(r.claims, capability)
@@ -97,7 +91,7 @@ function guard(capability) {
@@ -97,7 +91,7 @@ function guard(capability) {
if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx });
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 };
req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
next();
next();
- };
+ }
}
}
-module.exports = { AUDIENCE, ensureKey, setPublicKey, headers, invalidate, guard, viaProxy };
+module.exports = { AUDIENCE, keys, headers, invalidate, guard, viaProxy };
@@ -209,7 +209,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
@@ -209,7 +209,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
if (!haveEvents) return;
if (!haveEvents) return;
await h.db.run('UPDATE service_outbox SET sent_at = ? WHERE sent_at IS NULL', [Date.now()]); // earlier tests' rows
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' });
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();
firstSeq = await events.store.lastSeq();
const before = await sse(`/realtime/stream?topics=chat.message.*&last_event_id=${firstSeq - 1}`);
const before = await sse(`/realtime/stream?topics=chat.message.*&last_event_id=${firstSeq - 1}`);
await before.waitFor((c) => c.events().length === 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
@@ -217,7 +217,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
before.close();
before.close();
await h.db.deleteChatMessage(msgId, admin.id);
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 = [
const views = [
['anonymous', {}],
['anonymous', {}],
['signed in (cookie)', { Cookie: `ov_token=${userJwt('usr_01J9AAAAAAAAAAAAAAAAAAAAAA')}` }],
['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
@@ -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 () => {
t('e2e: only svc:chat can redact Chat’s events', async () => {
if (!haveEvents) return;
if (!haveEvents) return;
const id = await say('not yours to delete');
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 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 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`, {
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 () => {
@@ -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.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');
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.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');
assert.ok((await events.store.getEvent(created.id)).redacted_at, 'Chat itself can');
});
});
@@ -48,10 +48,12 @@ t('staff_caps come from the token; its role only raises the projection\'s', asyn
@@ -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 () => {
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;
const before = h.live.requests.filter((p) => p.endsWith('/auth')).length;
for (const bad of [
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, { iss: 'https://evil.example' }),
claimsFor(alice, { nbf: now() + 600 }),
claimsFor(alice, { nbf: now() + 600 }),
claimsFor(alice, { typ: 'service' }),
claimsFor(alice, { typ: 'service' }),
+ claimsFor(alice, { typ: 'realtime', purpose: 'realtime' }),
+ claimsFor(alice, { typ: 'fedcm' }),
claimsFor(alice, { actor_type: 'service' }),
claimsFor(alice, { actor_type: 'service' }),
(() => { const c = claimsFor(alice); delete c.exp; return c; })(),
(() => { 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
@@ -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(await session.authenticate(tok), null, JSON.stringify(bad));
assert.strictEqual(session.failureReason(tok), 'invalid');
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');
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',
@@ -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');
const auth = await ws.next((m) => m.type === 'auth');
assert.deepStrictEqual([auth.authenticated, auth.core_username, auth.user_id], [true, 'alice', alice.id]);
assert.deepStrictEqual([auth.authenticated, auth.core_username, auth.user_id], [true, 'alice', alice.id]);
ws.close();
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; }
} finally { h.live.down = false; }
});
});