openvibers/openvibe.chat Commits

Network-token auth in Chat (WS-I task 3): a Network session JWT is verified here with the Network's key and resolved through the ctx_users projection by subject (new index), carrying the token's staff_caps and a role that only ever raises the projection's, so signed-in people keep chatting while Live is down. Live is asked only for hbt_ tokens, non-JWTs, a key we do not hold and subjects the projection cannot place (first visits create the account there). test/network-session.test.js

OP OpenVibers committed 24 Sept 2026 · 3ebc5a113e · 5 files, +204 −5


Changes

ddiff --git a/server/app.js b/server/app.js
iindex 724718d..d0acf02 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -175,7 +175,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
async function isBanExemptAdmin(req) {
if (req._ovBanUser === undefined) {
let user = null;
-- try { const t = extractToken(req); user = t ? await ctx.authenticate(t) : null; } catch { user = null; }
++ try { const t = extractToken(req); user = t ? await require('./auth/network-session').authenticate(t) : null; } catch { user = null; }
req._ovBanUser = user;
}
const u = req._ovBanUser;
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex 22dfc27..37010c3 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -10,6 +10,7 @@
'use strict';
const ctx = require('../live-context');
++const session = require('./network-session');
/**
* What an hbt_ API token may do over REST, by scope (Live's apiTokenAllows, verbatim).
@@@ -93,7 +94,7 @@ function extractWsToken(req) {
*/
async function authenticateWs(token) {
if (!token) return null;
-- const user = await ctx.authenticate(token);
++ const user = await session.authenticate(token);
if (user && user.auth_source === 'api_token') user._authSource = 'api_token';
return user;
}
@@@ -107,9 +108,9 @@ async function requireAuth(req, res, next) {
return res.status(401).json({ error: 'Authentication required' });
}
let user = null;
-- try { user = await ctx.authenticate(token); } catch { user = null; }
++ try { user = await session.authenticate(token); } catch { user = null; }
if (!user) {
-- return ctx.authFailureReason(token) === 'unresolved'
++ return session.failureReason(token) === 'unresolved'
? res.status(401).json({ error: 'Unable to resolve account' })
: res.status(401).json({ error: 'Invalid or expired token' });
}
@@@ -140,7 +141,7 @@ async function optionalAuth(req, res, next) {
const token = extractToken(req);
if (token) {
let user = null;
-- try { user = await ctx.authenticate(token); } catch { user = null; }
++ try { user = await session.authenticate(token); } catch { user = null; }
if (user && !user.is_banned) {
if (user.auth_source === 'api_token') {
if (apiTokenAllows(req, user.scopes)) {
ddiff --git a/server/auth/network-session.js b/server/auth/network-session.js
nnew file mode 100644
iindex 0000000..a9cdd26
---- /dev/null
++++ b/server/auth/network-session.js
@@@ -0,0 +1,97 @@
++/**
++ * 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
++ * 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);
++ * - 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.
++ *
++ * The user carries the token's staff_caps (permissions.can reads them first) and its role when
++ * that is higher than the projection's; a lower one is ignored, as Live does (downgrades arrive
++ * from the Network over Live, then the bridge).
++ */
++'use strict';
++
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++const config = require('../config');
++const db = require('../db/database');
++const serviceAuth = require('../net/service-auth');
++const ctx = require('../live-context');
++
++const ROLE_RANK = { user: 0, streamer: 1, global_mod: 2, admin: 3 };
++const stats = { local: 0, live: 0, rejected: 0 };
++const _reasons = new Map(); // sha256(token) → 'invalid' for tokens refused here (bounded)
++const tokenKey = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
++
++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 };
++}
++
++/** The projected user for a verified token, or null when the projection cannot say which one. */
++function userFor(claims) {
++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
++ if (!subject) return null;
++ const rows = db.all('SELECT * FROM ctx_users WHERE subject_id = ? LIMIT 2', [subject]);
++ if (rows.length !== 1) return null;
++ const row = rows[0];
++ const user = {
++ id: row.id, username: row.username, display_name: row.display_name, avatar_url: row.avatar_url || null,
++ profile_color: row.profile_color || null, role: row.role || 'user', is_banned: row.is_banned ? 1 : 0, ban_reason: row.ban_reason || null,
++ is_owner: row.is_owner ? 1 : 0, created_at: row.created_at, subject_id: subject, auth_source: 'network',
++ };
++ if (claims.role && ROLE_RANK[claims.role] !== undefined && ROLE_RANK[claims.role] > (ROLE_RANK[user.role] ?? 0)) user.role = claims.role;
++ if (Array.isArray(claims.staff_caps)) user.staff_caps = claims.staff_caps.filter((c) => typeof c === 'string');
++ return user;
++}
++
++function refuse(token) {
++ stats.rejected++;
++ _reasons.set(tokenKey(token), 'invalid');
++ if (_reasons.size > 10000) _reasons.delete(_reasons.keys().next().value);
++ return null;
++}
++
++/** Resolve a browser/bot token to a chat user (a fresh object) or null. */
++async function authenticate(token) {
++ if (!token) return null;
++ const viaLive = async () => { stats.live++; return ctx.authenticate(token); };
++ if (String(token).startsWith('hbt_')) return viaLive();
++ const key = await serviceAuth.ensureKey();
++ if (!key) return 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' ? viaLive() : refuse(token);
++ const user = userFor(r.claims);
++ if (!user) return viaLive();
++ stats.local++;
++ return user;
++}
++
++/** Why the last resolution of this token failed: 'invalid' (bad/expired) or 'unresolved' (no account). */
++function failureReason(token) {
++ return (token && _reasons.get(tokenKey(token))) || ctx.authFailureReason(token);
++}
++
++module.exports = { authenticate, failureReason, verify, userFor, stats: () => ({ ...stats }) };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex f1bc2f6..3c227e1 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -103,6 +103,7 @@ function close() {
const ADDED_COLUMNS = [
['emotes', 'media_url', 'TEXT'], // Live: emote images synced to OpenVibe.Media
['emotes', 'media_asset_id', 'INTEGER'],
++ ['ctx_users', 'subject_id', 'TEXT'], // older Chat databases; auth/network-session.js looks users up by it
];
function initDb({ captureMirror = false } = {}) {
@@@ -113,6 +114,8 @@ function initDb({ captureMirror = false } = {}) {
const have = d.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);
if (!have) d.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
}
++ // auth/network-session.js resolves a verified Network token to its user by subject.
++ d.exec('CREATE INDEX IF NOT EXISTS idx_ctx_users_subject ON ctx_users(subject_id)');
const upsertAuth = d.prepare('INSERT INTO table_authority (table_name, authority, note) VALUES (?, ?, ?) ON CONFLICT(table_name) DO UPDATE SET authority = excluded.authority, note = excluded.note');
for (const t of Object.keys(CHAT_TABLES)) upsertAuth.run(t, 'chat', 'Chat writes; Live keeps a read mirror');
for (const [t, note] of Object.entries(STAGED_TABLES)) upsertAuth.run(t, 'live', note);
ddiff --git a/test/network-session.test.js b/test/network-session.test.js
nnew file mode 100644
iindex 0000000..92b26f6
---- /dev/null
++++ b/test/network-session.test.js
@@@ -0,0 +1,98 @@
++'use strict';
++/**
++ * Network-token auth in Chat (WS-I task 3, server/auth/network-session.js): a Network session JWT is
++ * verified with the Network's key and resolved through the ctx_users projection by subject, so a
++ * signed-in person keeps chatting while Live is down; Live is asked only for hbt_ tokens, non-JWTs,
++ * a key we do not hold, and subjects the projection cannot place. Staff powers come from the token's
++ * staff_caps; a role in the token only ever raises the projection's.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++const { boot, suite, b64url } = require('./helpers');
++
++const t = suite('network session');
++let h, session, alice, carol, modSubject;
++const now = () => Math.floor(Date.now() / 1000);
++const jwt = (claims, key = h.keys.privateKey, header = { alg: 'RS256', typ: 'JWT' }) => {
++ const head = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
++ return `${head}.${b64url(crypto.sign('RSA-SHA256', Buffer.from(head), key))}`;
++};
++const claimsFor = (u, extra = {}) => ({ iss: h.ISS, sub: String(u.id + 7000), subject_id: u.subject_id, username: u.username, role: u.role, iat: now(), exp: now() + 3600, ...extra });
++
++t('boot', async () => {
++ h = await boot();
++ session = require('../server/auth/network-session');
++ alice = h.addUser('alice', { subject: ids.newId('user') });
++ carol = h.addUser('carol', { subject: ids.newId('user'), role: 'global_mod' });
++ modSubject = ids.newId('user');
++ for (const u of [alice, carol]) h.ctx.upsertUser(h.live.users.get(u.id));
++});
++
++t('a Network token for a projected subject resolves here, without Live', async () => {
++ const before = h.live.requests.filter((p) => p.endsWith('/auth')).length;
++ const u = await session.authenticate(jwt(claimsFor(alice)));
++ assert.deepStrictEqual([u.id, u.username, u.subject_id, u.auth_source, u.role], [alice.id, 'alice', alice.subject_id, 'network', 'user']);
++ assert.strictEqual(h.live.requests.filter((p) => p.endsWith('/auth')).length, before, 'Live was not asked');
++ assert.ok(session.stats().local >= 1);
++});
++
++t('staff_caps come from the token; its role only raises the projection\'s', async () => {
++ const u = await session.authenticate(jwt(claimsFor(alice, { staff_caps: ['staff.moderation.chat'] })));
++ assert.deepStrictEqual(u.staff_caps, ['staff.moderation.chat']);
++ assert.strictEqual(require('../server/auth/permissions').can(u, 'staff.moderation.chat'), true);
++ assert.strictEqual((await session.authenticate(jwt(claimsFor(alice, { role: 'global_mod' })))).role, 'global_mod', 'an upgrade applies at once');
++ assert.strictEqual((await session.authenticate(jwt(claimsFor(carol, { role: 'user' })))).role, 'global_mod', 'a stale lower role is ignored');
++});
++
++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, { iss: 'https://evil.example' }),
++ claimsFor(alice, { nbf: now() + 600 }),
++ claimsFor(alice, { typ: 'service' }),
++ claimsFor(alice, { actor_type: 'service' }),
++ (() => { const c = claimsFor(alice); delete c.exp; return c; })(),
++ ]) {
++ const tok = jwt(bad);
++ 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(h.live.requests.filter((p) => p.endsWith('/auth')).length, before, 'no Live call for a token we can judge');
++});
++
++t('Live answers what the projection cannot: unknown subjects, other keys, hbt_ and opaque tokens', async () => {
++ const other = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++ const count = () => h.live.requests.filter((p) => p.endsWith('/auth')).length;
++ let n = count();
++ assert.strictEqual(await session.authenticate(jwt({ ...claimsFor(alice), subject_id: modSubject })), null, 'Live does not know this stub token');
++ assert.strictEqual(count(), n + 1, 'unknown subject → Live (it creates first-time accounts)');
++ n = count();
++ await session.authenticate(jwt(claimsFor(alice), other.privateKey));
++ assert.strictEqual(count(), n + 1, 'another key → Live');
++ n = count();
++ const bot = h.addUser('botty', { apiScopes: ['chat'] });
++ const u = await session.authenticate(bot.token);
++ assert.deepStrictEqual([u.id, u.auth_source], [bot.id, 'api_token'], 'API (and opaque) tokens still resolve through Live');
++ assert.strictEqual(count(), n + 1);
++});
++
++t('REST and the WebSocket keep working for a Network token while Live is down', async () => {
++ const tok = jwt(claimsFor(alice));
++ h.live.down = true;
++ try {
++ const r = await h.http('GET', '/api/chat/search?q=hello', { token: tok });
++ assert.notStrictEqual(r.status, 401, `REST authenticated without Live (${r.status})`);
++ const ws = await h.ws({ token: tok });
++ ws.sendJson({ type: 'join', token: tok });
++ 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);
++ } finally { h.live.down = false; }
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });