openvibers/openvibe.network Commits
Revocation propagation (WS-B task 4, Contracts 0.39.0): revokeTokens() moves token_valid_after, ends Network sessions and queues network.user.token_valid_after in the same transaction; password change and reset, bans, POST /api/auth/sign-out-everywhere and admin POST /users/:id/sign-out use it. 'Revoke All Sessions' only deactivated session rows while every token stayed valid: it is now 'Sign out other devices' (all tokens end, this browser gets a fresh one), next to 'Sign out everywhere, this device too'. test/revocation.test.js
Changes
@@@ -20,7 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","uuid": "^11.1.0",@@@ -1465,9 +1465,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.38.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",-- "integrity": "sha512-nHYCcKM9N4TJS+BlHOax9xwt4jU87qhXsLZ2gnuK4nOHpGlkLk2OI49PCQVvfHPe+V7bzBK7L4MKjFcTNv8ORA==",++ "version": "0.39.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",++ "integrity": "sha512-ek3tryU3pPwmFSg621gzr4ss1OE/gpufBnxk9XFDLSfUe2RuZpYVZCJNdQ0F/9GfA83Xop+xJW8+IjRyjFg6Yg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -23,7 +23,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","uuid": "^11.1.0",@@@ -457,8 +457,10 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat<div class="card"><h3><span class="icon"><i class="fa-solid fa-mobile-screen"></i></span> Active Sessions</h3><div id="sessions-list"><div class="loading-spinner">Loading sessions...</div></div>-- <div class="btn-row" style="margin-top:12px">-- <button class="btn btn-danger" onclick="revokeAllSessions()">Revoke All Sessions</button>++ <p style="margin-top:12px;color:var(--text-secondary);font-size:13px">Lost a phone or used a shared computer? Signing out ends every sign-in on every OpenVibe site at once, and open chats and games disconnect.</p>++ <div class="btn-row" style="margin-top:10px">++ <button class="btn btn-danger" onclick="revokeAllSessions()"><i class="fa-solid fa-mobile-screen-button"></i> Sign out other devices</button>++ <button class="btn" onclick="signOutEverywhere()"><i class="fa-solid fa-right-from-bracket"></i> Sign out everywhere, this device too</button></div></div></div>@@@ -1361,9 +1363,24 @@ async function revokeSession(id) {}async function revokeAllSessions() {-- if (!confirm('Revoke all sessions? You will need to log in again on other devices.')) return;-- try { await apiFetch('/api/auth/sessions', { method: 'DELETE' }); loadSessions(); }-- catch (err) { alert('Error: ' + err.message); }++ if (!confirm('Sign out every other device? They will need to sign in again. You stay signed in here.')) return;++ try {++ const data = await apiFetch('/api/auth/sessions', { method: 'DELETE' });++ if (data.token) {++ localStorage.setItem('ov_token', data.token);++ document.cookie = `ov_token=${data.token};path=/;max-age=${60*60*24*30};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;++ }++ loadSessions();++ alert('Signed out everywhere else.');++ } catch (err) { alert('Error: ' + err.message); }++}++++async function signOutEverywhere() {++ if (!confirm('Sign out on every device and every OpenVibe site, this one included?')) return;++ try { await apiFetch('/api/auth/sign-out-everywhere', { method: 'POST' }); }++ catch (err) { alert('Error: ' + err.message); return; }++ try { localStorage.removeItem('ov_token'); } catch { /* storage may be blocked */ }++ location.href = '/sso/fanout?action=logout&next=%2Flogin';}// Anon info@@@ -18,6 +18,8 @@ const { isOwner, requireOwner, isSensitiveSettingKey, maskSecret } = require('..const secrets = require('../secrets');const { issueResetToken } = require('../auth/reset-tokens');const { checkAdminLimit, recordAdminAction } = require('../auth/admin-limits');++const revocation = require('../auth/revocation');++const staffActor = (u) => (u && /^usr_/.test(String(u.subject_id || '')) ? { type: 'user', id: u.subject_id } : { type: 'system', id: 'network' });const LOCAL_REFRESH_SERVICES = new Set(['network']);@@@ -719,6 +721,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, banned ? 'user_ban' : 'user_unban', JSON.stringify({ targetId: req.params.id, reason }));++ // A ban ends the person's tokens on every site at once (network.user.token_valid_after).++ if (banned) { revocation.revokeTokens(db, Number(req.params.id), { reason: 'banned', actor: staffActor(req.user) }); revocation.kick(db); }// Notify the userif (banned) {@@@ -738,6 +742,21 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}});++ // End someone's sessions on every device and site (a stolen token, a shared computer).++ router.post('/users/:id/sign-out', (req, res) => {++ try {++ const target = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);++ if (!target) return res.status(404).json({ ok: false, error: 'No such user' });++ if (isOwner(target) && !isOwner(req.user)) return res.status(403).json({ ok: false, error: "Only the owner ends the owner's sessions" });++ const { validAfter } = revocation.revokeTokens(db, target.id, { reason: 'staff_revoked', actor: staffActor(req.user) });++ revocation.kick(db);++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_sign_out', JSON.stringify({ targetId: target.id, valid_after: validAfter }));++ res.json({ ok: true, valid_after: validAfter });++ } catch (err) {++ res.status(500).json({ ok: false, error: err.message });++ }++ });++// ═══════════════════════════════════════════════════════// Broadcast Notifications// ═══════════════════════════════════════════════════════nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++/**++ * Ending a person's tokens everywhere (roadmap WS-B task 4; Contracts 0.39.0++ * network.user.token_valid_after).++ *++ * revokeTokens() moves users.token_valid_after to now, ends their Network sessions and, in the same++ * transaction, queues network.user.token_valid_after in network_event_outbox (relayed to++ * OpenVibe.Events by server/developer/event-relay.js). Network itself already refuses a token whose++ * iat * 1000 < token_valid_after (auth/session.js, /api/auth/refresh) and a refresh token created++ * before it (auth/oauth-routes.js); the event makes Chat, Community, Live, Media, Tools and Games do++ * the same and close the sockets those tokens opened.++ *++ * Reasons: password_changed | password_reset | signed_out_everywhere | banned | account_deleted |++ * staff_revoked. Call it inside the caller's transaction when there is one (it nests), then kick().++ */++const { ids, validate } = require('openvibe-contracts');++const eventRelay = require('../developer/event-relay');++++const EVENT_TYPE = 'network.user.token_valid_after';++const REASONS = ['password_changed', 'password_reset', 'signed_out_everywhere', 'banned', 'account_deleted', 'staff_revoked'];++++const iso = (v) => new Date(String(v) + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(v)) ? '' : 'Z')).toISOString();++++function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {++ const payload = { subject: { type: 'user', id: subjectId }, valid_after: validAfter, reason };++ const pv = validate(`${EVENT_TYPE}@1`, payload);++ if (!pv.valid) throw new Error(`revocation: bad payload: ${pv.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);++ const ms = Date.now();++ const env = {++ event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',++ actor, timestamp: new Date(ms).toISOString(), visibility: 'internal',++ subject: { type: 'user', id: subjectId }, payload,++ };++ if (ctx && /^[0-9a-f]{32}$/.test(ctx.traceId || '')) env.trace_id = ctx.traceId;++ const v = validate('events.event-envelope@1', env);++ if (!v.valid) throw new Error(`revocation: bad envelope: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);++ return env;++}++++/**++ * @param {object} db better-sqlite3 database++ * @param {number} userId users.id++ * @param {{ reason: string, actor?: object, ctx?: object }} opts actor defaults to the person themself++ * @returns {{ validAfter: string, event: object|null }} event is null for an account with no subject++ */++function revokeTokens(db, userId, { reason, actor, ctx } = {}) {++ if (!REASONS.includes(reason)) throw new Error(`revocation: unknown reason ${reason}`);++ return db.transaction(() => {++ const user = db.prepare('SELECT id, subject_id FROM users WHERE id = ?').get(userId);++ if (!user) throw new Error(`revocation: no user ${userId}`);++ db.prepare('UPDATE users SET token_valid_after = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);++ db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(user.id);++ const validAfter = iso(db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id).t);++ let event = null;++ if (ids.isSubjectId('user', user.subject_id)) {++ const by = actor || { type: 'user', id: user.subject_id };++ event = buildEnvelope({ subjectId: user.subject_id, validAfter, reason, actor: by, ctx });++ eventRelay.writerFor(db).enqueue(event, { traceparent: ctx && ctx.traceparent });++ }++ return { validAfter, event };++ })();++}++++/** After the commit: wake the relay if this process runs one. */++function kick(db) {++ const live = eventRelay.outboxFor(db);++ if (live) live.kick();++}++++module.exports = { revokeTokens, kick, buildEnvelope, EVENT_TYPE, REASONS };@@@ -12,6 +12,7 @@ const jwt = require('jsonwebtoken');const { v4: uuidv4 } = require('uuid');const subjects = require('../identity/subjects');const { staffClaims } = require('./staff-claims');++const revocation = require('./revocation');const router = express.Router();@@@ -269,15 +270,13 @@ router.post('/reset-password', (req, res) => {const hash = bcrypt.hashSync(newPassword, 10);const tx = db.transaction(() => {-- db.prepare(`-- UPDATE users-- SET password_hash = ?, token_valid_after = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP-- WHERE id = ?-- `).run(hash, reset.user_id);-- db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(reset.user_id);++ db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, reset.user_id);++ // Every token and session from before the reset ends, on every site (network.user.token_valid_after).++ revocation.revokeTokens(db, reset.user_id, { reason: 'password_reset', ctx: req.ov });db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(reset.user_id);});tx();++ revocation.kick(db);if (notifService) {notifService.create({@@@ -439,10 +438,13 @@ router.post('/change-password', requireAuth, (req, res) => {}const hash = bcrypt.hashSync(new_password, 10);-- db.prepare('UPDATE users SET password_hash = ?, token_valid_after = CURRENT_TIMESTAMP WHERE id = ?')-- .run(hash, req.user.id);-- db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(req.user.id);-- db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(req.user.id);++ db.transaction(() => {++ db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id);++ // Every other token and session ends, on every site; this one gets a fresh token below.++ revocation.revokeTokens(db, req.user.id, { reason: 'password_changed', ctx: req.ov });++ db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(req.user.id);++ })();++ revocation.kick(db);// Issue fresh tokenconst user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);@@@ -509,6 +511,20 @@ function logAnonIp(db, anonId, ip) {// POST /api/auth/anon-session// Creates a temporary anonymous identity with a unique number.// If force_new=true, always creates a new anon identity.++// ── Sign out on every device ─────────────────────────────────++// POST /api/auth/sign-out-everywhere: every token and session this person holds ends, here and (via++// network.user.token_valid_after) on every OpenVibe site; open chat and game sockets close. The page++// then runs the sign-out fanout so this browser's site sessions are cleared too.++router.post('/sign-out-everywhere', requireAuth, (req, res) => {++ const db = getDb(req);++ const { validAfter } = revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });++ revocation.kick(db);++ try { db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'sign_out_everywhere', JSON.stringify({ valid_after: validAfter })); } catch { /* audit is best effort */ }++ require('./session').clearSessionCookies(res);++ res.set('Cache-Control', 'no-store');++ res.json({ ok: true, valid_after: validAfter });++});++// Server-side sign-out: the page-side storage is cleared by the caller; this drops the cookies// only the server can (ov_sso is httpOnly). Idempotent, no auth required.router.post('/logout', (req, res) => {@@@ -726,12 +742,17 @@ router.delete('/sessions/:id', requireAuth, (req, res) => {res.json({ ok: true, revoked: result.changes > 0 });});--// ── Revoke All Sessions ──────────────────────────────────────--// DELETE /api/auth/sessions++// ── Sign out other devices ───────────────────────────────────++// DELETE /api/auth/sessions: every token and session ends on every site (network.user.token_valid_after),++// and this browser gets a fresh token so it stays signed in (same as a password change).router.delete('/sessions', requireAuth, (req, res) => {const db = getDb(req);-- const result = db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(req.user.id);-- res.json({ ok: true, revoked: result.changes });++ const active = db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(req.user.id).n;++ revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });++ revocation.kick(db);++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);++ res.set('Cache-Control', 'no-store');++ res.json({ ok: true, revoked: active, token: signToken(user, req.app.locals.privateKey, getConfig(req)) });});// ═══════════════════════════════════════════════════════════════nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++// Revocation propagation (WS-B task 4, Contracts 0.39.0 network.user.token_valid_after): signing out++// every device, a password change, a reset and a ban move the cutoff, end Network's sessions and queue++// one validated event for the person's subject in the same transaction; older tokens stop working here.++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const cookieParser = require('cookie-parser');++const jwt = require('jsonwebtoken');++const bcrypt = require('bcryptjs');++const { validate } = require('openvibe-contracts');++const { initDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const revocation = require('../server/auth/revocation');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-revoke-'));++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++const ISSUER = 'https://openvibe.network';++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };++++const hash = bcrypt.hashSync('oldpass1', 4);++const mk = (name, withSubject = true) => {++ const id = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(name, hash).lastInsertRowid;++ if (withSubject) subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ else db.prepare('UPDATE users SET subject_id = NULL WHERE id = ?').run(id);++ return db.prepare('SELECT * FROM users WHERE id = ?').get(id);++};++// A session token issued 10 seconds ago (the cutoff has second precision; a token from the same second survives).++const oldToken = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id || undefined, username: u.username, role: u.role || 'user', iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === 'network.user.token_valid_after');++++const app = express();++app.locals.db = db; app.locals.config = config; app.locals.privateKey = keys.privateKey; app.locals.publicKey = keys.publicKey;++app.use(express.json()); app.use(cookieParser());++app.use('/api/auth', require('../server/auth/routes'));++const server = http.createServer(app);++++(async () => {++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}/api/auth`;++ const call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })++ .then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));++ try {++ revocation.revokeTokens.length; // module loads++ assert.throws(() => revocation.revokeTokens(db, 1, { reason: 'because' }), /unknown reason/);++++ // Sign out everywhere, this device too.++ const ann = mk('ann'); const annTok = oldToken(ann);++ assert.strictEqual((await call('GET', '/me', annTok)).status, 200);++ db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-ann', 1, datetime('now', '+1 day'))").run(ann.id);++ let r = await call('POST', '/sign-out-everywhere', annTok);++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.strictEqual((await call('GET', '/me', annTok)).status, 401, 'the old token is refused');++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(ann.id).n, 0);++ let ev = events();++ assert.strictEqual(ev.length, 1);++ assert.deepStrictEqual([ev[0].source, ev[0].subject, ev[0].payload.subject.id, ev[0].payload.reason, ev[0].payload.valid_after], ['network', { type: 'user', id: ann.subject_id }, ann.subject_id, 'signed_out_everywhere', r.body.valid_after]);++ assert.ok(validate('events.event-envelope@1', ev[0]).valid && validate('network.user.token_valid_after@1', ev[0].payload).valid);++ assert.deepStrictEqual(ev[0].actor, { type: 'user', id: ann.subject_id }, 'the person did it');++++ // Sign out other devices: the old token dies, this browser gets a fresh one.++ const bob = mk('bob'); const bobTok = oldToken(bob);++ r = await call('DELETE', '/sessions', bobTok);++ assert.strictEqual(r.status, 200);++ assert.ok(r.body.token);++ assert.strictEqual((await call('GET', '/me', bobTok)).status, 401);++ assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200, 'the fresh token works');++ assert.strictEqual(events().at(-1).payload.reason, 'signed_out_everywhere');++++ // Password change: every older token ends; the reply's token works.++ const cat = mk('cat'); const catTok = oldToken(cat);++ r = await call('POST', '/change-password', catTok, { current_password: 'oldpass1', new_password: 'newpass2' });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.strictEqual((await call('GET', '/me', catTok)).status, 401);++ assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200);++ assert.deepStrictEqual([events().at(-1).payload.reason, events().at(-1).payload.subject.id], ['password_changed', cat.subject_id]);++++ // A ban (staff actor) and an account without a subject (no event, the cutoff still moves).++ const dan = mk('dan');++ revocation.revokeTokens(db, dan.id, { reason: 'banned', actor: { type: 'user', id: ann.subject_id } });++ assert.deepStrictEqual([events().at(-1).payload.reason, events().at(-1).actor.id], ['banned', ann.subject_id]);++ const eve = mk('eve', false);++ const n = events().length;++ const out = revocation.revokeTokens(db, eve.id, { reason: 'staff_revoked' });++ assert.strictEqual(out.event, null);++ assert.strictEqual(events().length, n);++ assert.ok(db.prepare('SELECT token_valid_after FROM users WHERE id = ?').get(eve.id).token_valid_after);++++ // Wiring: reset and ban call revokeTokens; the admin can end someone's sessions.++ const authSrc = fs.readFileSync(path.join(__dirname, '../server/auth/routes.js'), 'utf8');++ assert.ok(/revokeTokens\(db, reset\.user_id, \{ reason: 'password_reset'/.test(authSrc));++ const adminSrc = fs.readFileSync(path.join(__dirname, '../server/admin/routes.js'), 'utf8');++ assert.ok(/reason: 'banned'/.test(adminSrc) && /router\.post\('\/users\/:id\/sign-out'/.test(adminSrc) && /reason: 'staff_revoked'/.test(adminSrc));++ assert.ok(!/token_valid_after = CURRENT_TIMESTAMP/.test(authSrc), 'every cutoff goes through revokeTokens (so it is announced)');++ } finally {++ server.close();++ fs.rmSync(dir, { recursive: true, force: true });++ }++ console.log('revocation: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.38.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
- "integrity": "sha512-nHYCcKM9N4TJS+BlHOax9xwt4jU87qhXsLZ2gnuK4nOHpGlkLk2OI49PCQVvfHPe+V7bzBK7L4MKjFcTNv8ORA==",
+ "version": "0.39.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
+ "integrity": "sha512-ek3tryU3pPwmFSg621gzr4ss1OE/gpufBnxk9XFDLSfUe2RuZpYVZCJNdQ0F/9GfA83Xop+xJW8+IjRyjFg6Yg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
+ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -457,8 +457,10 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -457,8 +457,10 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<div class="card">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-mobile-screen"></i></span> Active Sessions</h3>
<h3><span class="icon"><i class="fa-solid fa-mobile-screen"></i></span> Active Sessions</h3>
<div id="sessions-list"><div class="loading-spinner">Loading sessions...</div></div>
<div id="sessions-list"><div class="loading-spinner">Loading sessions...</div></div>
- <div class="btn-row" style="margin-top:12px">
- <button class="btn btn-danger" onclick="revokeAllSessions()">Revoke All Sessions</button>
+ <p style="margin-top:12px;color:var(--text-secondary);font-size:13px">Lost a phone or used a shared computer? Signing out ends every sign-in on every OpenVibe site at once, and open chats and games disconnect.</p>
+ <div class="btn-row" style="margin-top:10px">
+ <button class="btn btn-danger" onclick="revokeAllSessions()"><i class="fa-solid fa-mobile-screen-button"></i> Sign out other devices</button>
+ <button class="btn" onclick="signOutEverywhere()"><i class="fa-solid fa-right-from-bracket"></i> Sign out everywhere, this device too</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1361,9 +1363,24 @@ async function revokeSession(id) {
@@ -1361,9 +1363,24 @@ async function revokeSession(id) {
}
}
async function revokeAllSessions() {
async function revokeAllSessions() {
- if (!confirm('Revoke all sessions? You will need to log in again on other devices.')) return;
- try { await apiFetch('/api/auth/sessions', { method: 'DELETE' }); loadSessions(); }
- catch (err) { alert('Error: ' + err.message); }
+ if (!confirm('Sign out every other device? They will need to sign in again. You stay signed in here.')) return;
+ try {
+ const data = await apiFetch('/api/auth/sessions', { method: 'DELETE' });
+ if (data.token) {
+ localStorage.setItem('ov_token', data.token);
+ document.cookie = `ov_token=${data.token};path=/;max-age=${60*60*24*30};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
+ }
+ loadSessions();
+ alert('Signed out everywhere else.');
+ } catch (err) { alert('Error: ' + err.message); }
+}
+
+async function signOutEverywhere() {
+ if (!confirm('Sign out on every device and every OpenVibe site, this one included?')) return;
+ try { await apiFetch('/api/auth/sign-out-everywhere', { method: 'POST' }); }
+ catch (err) { alert('Error: ' + err.message); return; }
+ try { localStorage.removeItem('ov_token'); } catch { /* storage may be blocked */ }
+ location.href = '/sso/fanout?action=logout&next=%2Flogin';
}
}
// Anon info
// Anon info
@@ -18,6 +18,8 @@ const { isOwner, requireOwner, isSensitiveSettingKey, maskSecret } = require('..
@@ -18,6 +18,8 @@ const { isOwner, requireOwner, isSensitiveSettingKey, maskSecret } = require('..
const secrets = require('../secrets');
const secrets = require('../secrets');
const { issueResetToken } = require('../auth/reset-tokens');
const { issueResetToken } = require('../auth/reset-tokens');
const { checkAdminLimit, recordAdminAction } = require('../auth/admin-limits');
const { checkAdminLimit, recordAdminAction } = require('../auth/admin-limits');
+const revocation = require('../auth/revocation');
+const staffActor = (u) => (u && /^usr_/.test(String(u.subject_id || '')) ? { type: 'user', id: u.subject_id } : { type: 'system', id: 'network' });
const LOCAL_REFRESH_SERVICES = new Set(['network']);
const LOCAL_REFRESH_SERVICES = new Set(['network']);
@@ -719,6 +721,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -719,6 +721,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, banned ? 'user_ban' : 'user_unban', JSON.stringify({ targetId: req.params.id, reason })
req.user.id, banned ? 'user_ban' : 'user_unban', JSON.stringify({ targetId: req.params.id, reason })
);
);
+ // A ban ends the person's tokens on every site at once (network.user.token_valid_after).
+ if (banned) { revocation.revokeTokens(db, Number(req.params.id), { reason: 'banned', actor: staffActor(req.user) }); revocation.kick(db); }
// Notify the user
// Notify the user
if (banned) {
if (banned) {
@@ -738,6 +742,21 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -738,6 +742,21 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
});
});
+ // End someone's sessions on every device and site (a stolen token, a shared computer).
+ router.post('/users/:id/sign-out', (req, res) => {
+ try {
+ const target = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);
+ if (!target) return res.status(404).json({ ok: false, error: 'No such user' });
+ if (isOwner(target) && !isOwner(req.user)) return res.status(403).json({ ok: false, error: "Only the owner ends the owner's sessions" });
+ const { validAfter } = revocation.revokeTokens(db, target.id, { reason: 'staff_revoked', actor: staffActor(req.user) });
+ revocation.kick(db);
+ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_sign_out', JSON.stringify({ targetId: target.id, valid_after: validAfter }));
+ res.json({ ok: true, valid_after: validAfter });
+ } catch (err) {
+ res.status(500).json({ ok: false, error: err.message });
+ }
+ });
+
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
// Broadcast Notifications
// Broadcast Notifications
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * Ending a person's tokens everywhere (roadmap WS-B task 4; Contracts 0.39.0
+ * network.user.token_valid_after).
+ *
+ * revokeTokens() moves users.token_valid_after to now, ends their Network sessions and, in the same
+ * transaction, queues network.user.token_valid_after in network_event_outbox (relayed to
+ * OpenVibe.Events by server/developer/event-relay.js). Network itself already refuses a token whose
+ * iat * 1000 < token_valid_after (auth/session.js, /api/auth/refresh) and a refresh token created
+ * before it (auth/oauth-routes.js); the event makes Chat, Community, Live, Media, Tools and Games do
+ * the same and close the sockets those tokens opened.
+ *
+ * Reasons: password_changed | password_reset | signed_out_everywhere | banned | account_deleted |
+ * staff_revoked. Call it inside the caller's transaction when there is one (it nests), then kick().
+ */
+const { ids, validate } = require('openvibe-contracts');
+const eventRelay = require('../developer/event-relay');
+
+const EVENT_TYPE = 'network.user.token_valid_after';
+const REASONS = ['password_changed', 'password_reset', 'signed_out_everywhere', 'banned', 'account_deleted', 'staff_revoked'];
+
+const iso = (v) => new Date(String(v) + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(v)) ? '' : 'Z')).toISOString();
+
+function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {
+ const payload = { subject: { type: 'user', id: subjectId }, valid_after: validAfter, reason };
+ const pv = validate(`${EVENT_TYPE}@1`, payload);
+ if (!pv.valid) throw new Error(`revocation: bad payload: ${pv.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
+ const ms = Date.now();
+ const env = {
+ event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
+ actor, timestamp: new Date(ms).toISOString(), visibility: 'internal',
+ subject: { type: 'user', id: subjectId }, payload,
+ };
+ if (ctx && /^[0-9a-f]{32}$/.test(ctx.traceId || '')) env.trace_id = ctx.traceId;
+ const v = validate('events.event-envelope@1', env);
+ if (!v.valid) throw new Error(`revocation: bad envelope: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
+ return env;
+}
+
+/**
+ * @param {object} db better-sqlite3 database
+ * @param {number} userId users.id
+ * @param {{ reason: string, actor?: object, ctx?: object }} opts actor defaults to the person themself
+ * @returns {{ validAfter: string, event: object|null }} event is null for an account with no subject
+ */
+function revokeTokens(db, userId, { reason, actor, ctx } = {}) {
+ if (!REASONS.includes(reason)) throw new Error(`revocation: unknown reason ${reason}`);
+ return db.transaction(() => {
+ const user = db.prepare('SELECT id, subject_id FROM users WHERE id = ?').get(userId);
+ if (!user) throw new Error(`revocation: no user ${userId}`);
+ db.prepare('UPDATE users SET token_valid_after = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
+ db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(user.id);
+ const validAfter = iso(db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id).t);
+ let event = null;
+ if (ids.isSubjectId('user', user.subject_id)) {
+ const by = actor || { type: 'user', id: user.subject_id };
+ event = buildEnvelope({ subjectId: user.subject_id, validAfter, reason, actor: by, ctx });
+ eventRelay.writerFor(db).enqueue(event, { traceparent: ctx && ctx.traceparent });
+ }
+ return { validAfter, event };
+ })();
+}
+
+/** After the commit: wake the relay if this process runs one. */
+function kick(db) {
+ const live = eventRelay.outboxFor(db);
+ if (live) live.kick();
+}
+
+module.exports = { revokeTokens, kick, buildEnvelope, EVENT_TYPE, REASONS };
@@ -12,6 +12,7 @@ const jwt = require('jsonwebtoken');
@@ -12,6 +12,7 @@ const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
const subjects = require('../identity/subjects');
const subjects = require('../identity/subjects');
const { staffClaims } = require('./staff-claims');
const { staffClaims } = require('./staff-claims');
+const revocation = require('./revocation');
const router = express.Router();
const router = express.Router();
@@ -269,15 +270,13 @@ router.post('/reset-password', (req, res) => {
@@ -269,15 +270,13 @@ router.post('/reset-password', (req, res) => {
const hash = bcrypt.hashSync(newPassword, 10);
const hash = bcrypt.hashSync(newPassword, 10);
const tx = db.transaction(() => {
const tx = db.transaction(() => {
- db.prepare(`
- UPDATE users
- SET password_hash = ?, token_valid_after = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
- WHERE id = ?
- `).run(hash, reset.user_id);
- db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(reset.user_id);
+ db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, reset.user_id);
+ // Every token and session from before the reset ends, on every site (network.user.token_valid_after).
+ revocation.revokeTokens(db, reset.user_id, { reason: 'password_reset', ctx: req.ov });
db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(reset.user_id);
db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(reset.user_id);
});
});
tx();
tx();
+ revocation.kick(db);
if (notifService) {
if (notifService) {
notifService.create({
notifService.create({
@@ -439,10 +438,13 @@ router.post('/change-password', requireAuth, (req, res) => {
@@ -439,10 +438,13 @@ router.post('/change-password', requireAuth, (req, res) => {
}
}
const hash = bcrypt.hashSync(new_password, 10);
const hash = bcrypt.hashSync(new_password, 10);
- db.prepare('UPDATE users SET password_hash = ?, token_valid_after = CURRENT_TIMESTAMP WHERE id = ?')
- .run(hash, req.user.id);
- db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(req.user.id);
- db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(req.user.id);
+ db.transaction(() => {
+ db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id);
+ // Every other token and session ends, on every site; this one gets a fresh token below.
+ revocation.revokeTokens(db, req.user.id, { reason: 'password_changed', ctx: req.ov });
+ db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(req.user.id);
+ })();
+ revocation.kick(db);
// Issue fresh token
// Issue fresh token
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
@@ -509,6 +511,20 @@ function logAnonIp(db, anonId, ip) {
@@ -509,6 +511,20 @@ function logAnonIp(db, anonId, ip) {
// POST /api/auth/anon-session
// POST /api/auth/anon-session
// Creates a temporary anonymous identity with a unique number.
// Creates a temporary anonymous identity with a unique number.
// If force_new=true, always creates a new anon identity.
// If force_new=true, always creates a new anon identity.
+// ── Sign out on every device ─────────────────────────────────
+// POST /api/auth/sign-out-everywhere: every token and session this person holds ends, here and (via
+// network.user.token_valid_after) on every OpenVibe site; open chat and game sockets close. The page
+// then runs the sign-out fanout so this browser's site sessions are cleared too.
+router.post('/sign-out-everywhere', requireAuth, (req, res) => {
+ const db = getDb(req);
+ const { validAfter } = revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });
+ revocation.kick(db);
+ try { db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'sign_out_everywhere', JSON.stringify({ valid_after: validAfter })); } catch { /* audit is best effort */ }
+ require('./session').clearSessionCookies(res);
+ res.set('Cache-Control', 'no-store');
+ res.json({ ok: true, valid_after: validAfter });
+});
+
// Server-side sign-out: the page-side storage is cleared by the caller; this drops the cookies
// Server-side sign-out: the page-side storage is cleared by the caller; this drops the cookies
// only the server can (ov_sso is httpOnly). Idempotent, no auth required.
// only the server can (ov_sso is httpOnly). Idempotent, no auth required.
router.post('/logout', (req, res) => {
router.post('/logout', (req, res) => {
@@ -726,12 +742,17 @@ router.delete('/sessions/:id', requireAuth, (req, res) => {
@@ -726,12 +742,17 @@ router.delete('/sessions/:id', requireAuth, (req, res) => {
res.json({ ok: true, revoked: result.changes > 0 });
res.json({ ok: true, revoked: result.changes > 0 });
});
});
-// ── Revoke All Sessions ──────────────────────────────────────
-// DELETE /api/auth/sessions
+// ── Sign out other devices ───────────────────────────────────
+// DELETE /api/auth/sessions: every token and session ends on every site (network.user.token_valid_after),
+// and this browser gets a fresh token so it stays signed in (same as a password change).
router.delete('/sessions', requireAuth, (req, res) => {
router.delete('/sessions', requireAuth, (req, res) => {
const db = getDb(req);
const db = getDb(req);
- const result = db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(req.user.id);
- res.json({ ok: true, revoked: result.changes });
+ const active = db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(req.user.id).n;
+ revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });
+ revocation.kick(db);
+ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
+ res.set('Cache-Control', 'no-store');
+ res.json({ ok: true, revoked: active, token: signToken(user, req.app.locals.privateKey, getConfig(req)) });
});
});
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+// Revocation propagation (WS-B task 4, Contracts 0.39.0 network.user.token_valid_after): signing out
+// every device, a password change, a reset and a ban move the cutoff, end Network's sessions and queue
+// one validated event for the person's subject in the same transaction; older tokens stop working here.
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const cookieParser = require('cookie-parser');
+const jwt = require('jsonwebtoken');
+const bcrypt = require('bcryptjs');
+const { validate } = require('openvibe-contracts');
+const { initDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const revocation = require('../server/auth/revocation');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-revoke-'));
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+const ISSUER = 'https://openvibe.network';
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
+
+const hash = bcrypt.hashSync('oldpass1', 4);
+const mk = (name, withSubject = true) => {
+ const id = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(name, hash).lastInsertRowid;
+ if (withSubject) subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ else db.prepare('UPDATE users SET subject_id = NULL WHERE id = ?').run(id);
+ return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
+};
+// A session token issued 10 seconds ago (the cutoff has second precision; a token from the same second survives).
+const oldToken = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id || undefined, username: u.username, role: u.role || 'user', iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === 'network.user.token_valid_after');
+
+const app = express();
+app.locals.db = db; app.locals.config = config; app.locals.privateKey = keys.privateKey; app.locals.publicKey = keys.publicKey;
+app.use(express.json()); app.use(cookieParser());
+app.use('/api/auth', require('../server/auth/routes'));
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/auth`;
+ const call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
+ .then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));
+ try {
+ revocation.revokeTokens.length; // module loads
+ assert.throws(() => revocation.revokeTokens(db, 1, { reason: 'because' }), /unknown reason/);
+
+ // Sign out everywhere, this device too.
+ const ann = mk('ann'); const annTok = oldToken(ann);
+ assert.strictEqual((await call('GET', '/me', annTok)).status, 200);
+ db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-ann', 1, datetime('now', '+1 day'))").run(ann.id);
+ let r = await call('POST', '/sign-out-everywhere', annTok);
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.strictEqual((await call('GET', '/me', annTok)).status, 401, 'the old token is refused');
+ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(ann.id).n, 0);
+ let ev = events();
+ assert.strictEqual(ev.length, 1);
+ assert.deepStrictEqual([ev[0].source, ev[0].subject, ev[0].payload.subject.id, ev[0].payload.reason, ev[0].payload.valid_after], ['network', { type: 'user', id: ann.subject_id }, ann.subject_id, 'signed_out_everywhere', r.body.valid_after]);
+ assert.ok(validate('events.event-envelope@1', ev[0]).valid && validate('network.user.token_valid_after@1', ev[0].payload).valid);
+ assert.deepStrictEqual(ev[0].actor, { type: 'user', id: ann.subject_id }, 'the person did it');
+
+ // Sign out other devices: the old token dies, this browser gets a fresh one.
+ const bob = mk('bob'); const bobTok = oldToken(bob);
+ r = await call('DELETE', '/sessions', bobTok);
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.body.token);
+ assert.strictEqual((await call('GET', '/me', bobTok)).status, 401);
+ assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200, 'the fresh token works');
+ assert.strictEqual(events().at(-1).payload.reason, 'signed_out_everywhere');
+
+ // Password change: every older token ends; the reply's token works.
+ const cat = mk('cat'); const catTok = oldToken(cat);
+ r = await call('POST', '/change-password', catTok, { current_password: 'oldpass1', new_password: 'newpass2' });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.strictEqual((await call('GET', '/me', catTok)).status, 401);
+ assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200);
+ assert.deepStrictEqual([events().at(-1).payload.reason, events().at(-1).payload.subject.id], ['password_changed', cat.subject_id]);
+
+ // A ban (staff actor) and an account without a subject (no event, the cutoff still moves).
+ const dan = mk('dan');
+ revocation.revokeTokens(db, dan.id, { reason: 'banned', actor: { type: 'user', id: ann.subject_id } });
+ assert.deepStrictEqual([events().at(-1).payload.reason, events().at(-1).actor.id], ['banned', ann.subject_id]);
+ const eve = mk('eve', false);
+ const n = events().length;
+ const out = revocation.revokeTokens(db, eve.id, { reason: 'staff_revoked' });
+ assert.strictEqual(out.event, null);
+ assert.strictEqual(events().length, n);
+ assert.ok(db.prepare('SELECT token_valid_after FROM users WHERE id = ?').get(eve.id).token_valid_after);
+
+ // Wiring: reset and ban call revokeTokens; the admin can end someone's sessions.
+ const authSrc = fs.readFileSync(path.join(__dirname, '../server/auth/routes.js'), 'utf8');
+ assert.ok(/revokeTokens\(db, reset\.user_id, \{ reason: 'password_reset'/.test(authSrc));
+ const adminSrc = fs.readFileSync(path.join(__dirname, '../server/admin/routes.js'), 'utf8');
+ assert.ok(/reason: 'banned'/.test(adminSrc) && /router\.post\('\/users\/:id\/sign-out'/.test(adminSrc) && /reason: 'staff_revoked'/.test(adminSrc));
+ assert.ok(!/token_valid_after = CURRENT_TIMESTAMP/.test(authSrc), 'every cutoff goes through revokeTokens (so it is announced)');
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ console.log('revocation: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });