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
Changes
@@@ -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;@@@ -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)) {nnew file mode 100644@@@ -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 }) };@@@ -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);nnew file mode 100644@@@ -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(); });
@@ -175,7 +175,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
@@ -175,7 +175,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
async function isBanExemptAdmin(req) {
async function isBanExemptAdmin(req) {
if (req._ovBanUser === undefined) {
if (req._ovBanUser === undefined) {
let user = null;
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;
req._ovBanUser = user;
}
}
const u = req._ovBanUser;
const u = req._ovBanUser;
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
'use strict';
'use strict';
const ctx = require('../live-context');
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).
* What an hbt_ API token may do over REST, by scope (Live's apiTokenAllows, verbatim).
@@ -93,7 +94,7 @@ function extractWsToken(req) {
@@ -93,7 +94,7 @@ function extractWsToken(req) {
*/
*/
async function authenticateWs(token) {
async function authenticateWs(token) {
if (!token) return null;
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';
if (user && user.auth_source === 'api_token') user._authSource = 'api_token';
return user;
return user;
}
}
@@ -107,9 +108,9 @@ async function requireAuth(req, res, next) {
@@ -107,9 +108,9 @@ async function requireAuth(req, res, next) {
return res.status(401).json({ error: 'Authentication required' });
return res.status(401).json({ error: 'Authentication required' });
}
}
let user = null;
let user = null;
- try { user = await ctx.authenticate(token); } catch { user = null; }
+ try { user = await session.authenticate(token); } catch { user = null; }
if (!user) {
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: 'Unable to resolve account' })
: res.status(401).json({ error: 'Invalid or expired token' });
: res.status(401).json({ error: 'Invalid or expired token' });
}
}
@@ -140,7 +141,7 @@ async function optionalAuth(req, res, next) {
@@ -140,7 +141,7 @@ async function optionalAuth(req, res, next) {
const token = extractToken(req);
const token = extractToken(req);
if (token) {
if (token) {
let user = null;
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 && !user.is_banned) {
if (user.auth_source === 'api_token') {
if (user.auth_source === 'api_token') {
if (apiTokenAllows(req, user.scopes)) {
if (apiTokenAllows(req, user.scopes)) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -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 }) };
@@ -103,6 +103,7 @@ function close() {
@@ -103,6 +103,7 @@ function close() {
const ADDED_COLUMNS = [
const ADDED_COLUMNS = [
['emotes', 'media_url', 'TEXT'], // Live: emote images synced to OpenVibe.Media
['emotes', 'media_url', 'TEXT'], // Live: emote images synced to OpenVibe.Media
['emotes', 'media_asset_id', 'INTEGER'],
['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 } = {}) {
function initDb({ captureMirror = false } = {}) {
@@ -113,6 +114,8 @@ 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);
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}`);
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');
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 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);
for (const [t, note] of Object.entries(STAGED_TABLES)) upsertAuth.run(t, 'live', note);
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -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(); });