openvibers/openvibe.network Commits

Refresh tokens (roadmap §18.2(2)): oauth_tokens held each refresh token in clear, and presenting a rotated token again only failed. Now only its SHA-256 is stored; every token belongs to the family of the sign-in that issued it (family_id) and carries a generation (0 at sign-in, +1 per rotation); presenting an already rotated token revokes every live token of its family (revoked_reason reuse) and writes an oauth_refresh_reuse audit row with the presented and current generation, never a token. A second use within 10 seconds of the rotation is refused without revoking the family (two tabs refreshing through Live's cookie at once). Boot adds the columns only. Rows written before keep working: a raw row is matched and hashed on first use, and a stored hash presented as a token matches nothing. scripts/hash-refresh-tokens.js hashes the rest in place, one-way: dry run by default, --apply requires --backup <new file> (online, 0600, quick_check), --restore-from <backup> [--apply] puts the raw values back for a rollback to the previous release; run as root it becomes the database owner first (scripts/lib/db-ops.js). server/auth/refresh-tokens.js; test/refresh-tokens.test.js

OP OpenVibers committed 24 Sept 2026 · 1cf0191c80 · 8 files, +481 −22


Changes

ddiff --git a/README.md b/README.md
iindex 98bce524..f04b87fb 100644
---- a/README.md
++++ b/README.md
@@@ -9,6 +9,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
## What it does
- **SSO Identity Provider** — "One Account. All of OpenVibe." Central registration and login, OAuth2 Authorization Code flow for OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games, and OpenVibe.Media.
++- **Refresh tokens** — stored only as a SHA-256, rotated on every use as the next `generation` of the sign-in's `family_id`; presenting an already rotated token again (after a 10-second grace for two tabs refreshing at once) revokes the whole family and is audited (`oauth_refresh_reuse`). Rows from before this change still work and are hashed on first use; `scripts/hash-refresh-tokens.js` hashes the rest in place (dry run by default, `--apply --backup <new file>`, rollback with `--restore-from <backup> --apply`). `server/auth/refresh-tokens.js`.
- **OpenID Connect** — discovery at the issuer's root, `/.well-known/openid-configuration` (also `/.well-known/oauth-authorization-server`, and the older `/oauth/.well-known/openid-configuration`). With scope `openid` the code grant adds an RS256 `id_token` (aud = client, the authorize `nonce` echoed, `sub` = the Network id as a string, `subject_id` = the canonical usr_ id); `/oauth/userinfo` returns the same claims for an access token (`server/auth/oidc.js`).
- **Unified Notification System** — Cross-service notifications with priority levels, category filtering, toast popups, bell badge, sounds, and rich content (buttons, inputs, media). All services push notifications to the central API; clients poll every 15 seconds.
- **Email Alerts** — Critical notifications (moderation actions, system alerts) can be delivered by the built-in email service. Configuration is managed through the admin panel.
ddiff --git a/package.json b/package.json
iindex ea21bf77..405e955c 100644
---- a/package.json
++++ b/package.json
@@@ -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/chrome.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",
++ "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/chrome.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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/scripts/hash-refresh-tokens.js b/scripts/hash-refresh-tokens.js
nnew file mode 100644
iindex 00000000..ce6da1d8
---- /dev/null
++++ b/scripts/hash-refresh-tokens.js
@@@ -0,0 +1,70 @@
++#!/usr/bin/env node
++/**
++ * Refresh tokens stored before server/auth/refresh-tokens.js held the raw token. The server already
++ * reads both (a raw row is hashed on its first use); this replaces the rest by their SHA-256 in place,
++ * one-way, so no usable token sits in network.db.
++ *
++ * node scripts/hash-refresh-tokens.js # dry run: counts only
++ * node scripts/hash-refresh-tokens.js --apply --backup <new file> # online backup (0600, verified), then hash
++ * node scripts/hash-refresh-tokens.js --restore-from <backup> # dry run of the rollback
++ * node scripts/hash-refresh-tokens.js --restore-from <backup> --apply
++ * puts the raw values from the backup back into rows that still hold their hash. Only needed to
++ * roll Network back to a release before refresh-token hashing: that release matches raw values only.
++ * --db <path> default $DB_PATH, else data/network.db (relative to the repo root)
++ *
++ * Safe while Network runs (batched transactions; busy_timeout 5 s). Run as the database's owner; run
++ * as root it becomes that owner before opening the database. Prints counts, never a token.
++ */
++'use strict';
++const Database = require('better-sqlite3');
++const ops = require('./lib/db-ops');
++const refreshTokens = require('../server/auth/refresh-tokens');
++
++const USAGE = `usage: node scripts/hash-refresh-tokens.js [--db <path>] [--apply --backup <new file>]
++ node scripts/hash-refresh-tokens.js [--db <path>] --restore-from <backup> [--apply]`;
++
++async function main(argv, log = console.log) {
++ let args;
++ try { args = ops.parseArgs(argv, { flags: ['apply'], values: ['db', 'backup', 'restore-from'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
++ if (args.help) { log(USAGE); return 0; }
++ const file = ops.dbPath(args);
++ if (!require('fs').existsSync(file)) { log(`error: no database at ${file}`); return 2; }
++ ops.dropToOwnerOf(file, log);
++ const db = new Database(file);
++ db.pragma('busy_timeout = 5000');
++ try {
++ refreshTokens.ensureSchema(db);
++ const total = db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens').get().n;
++
++ if (args['restore-from']) {
++ const backup = new Database(require('path').resolve(ops.ROOT, args['restore-from']), { readonly: true, fileMustExist: true });
++ const raw = backup.prepare(`SELECT id, token FROM oauth_tokens WHERE substr(token, 1, ${refreshTokens.PREFIX.length}) <> '${refreshTokens.PREFIX}'`).all();
++ backup.close();
++ const has = db.prepare('SELECT 1 FROM oauth_tokens WHERE id = ? AND token = ?');
++ const restorable = raw.filter(r => has.get(r.id, refreshTokens.hash(r.token)));
++ log(`database ${file}\nbackup has ${raw.length} raw token(s); ${restorable.length} row(s) here still hold their hash`);
++ if (!args.apply) { log('dry run: nothing changed. Re-run with --apply to put the raw values back.'); return 0; }
++ const up = db.prepare('UPDATE oauth_tokens SET token = ? WHERE id = ? AND token = ?');
++ let n = 0;
++ db.transaction(() => { for (const r of restorable) n += up.run(r.token, r.id, refreshTokens.hash(r.token)).changes; })();
++ log(`restored ${n} raw token(s)`);
++ return 0;
++ }
++
++ const legacy = refreshTokens.countLegacy(db);
++ log(`database ${file}\nrefresh tokens ${total}; still stored raw ${legacy}; hashed ${total - legacy}`);
++ if (!args.apply) { log(legacy ? 'dry run: nothing changed. Re-run with --apply --backup <new file>.' : 'nothing to do.'); return 0; }
++ if (!args.backup) { log('refusing --apply without --backup <new file> (sqlite online backup, 0600)'); return 2; }
++ if (!legacy) { log('nothing to do; no backup taken.'); return 0; }
++ const target = await ops.backupTo(db, args.backup, { expect: { table: 'oauth_tokens', rows: total } });
++ log(`backup ${target} (0600, quick_check ok). It holds the raw tokens: delete it once the rollback window is over.`);
++ const n = refreshTokens.hashLegacy(db);
++ const left = refreshTokens.countLegacy(db);
++ log(`hashed ${n} token(s) in place; raw left ${left}`);
++ return left ? 1 : 0;
++ } finally { db.close(); }
++}
++
++if (require.main === module) main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
++
++module.exports = { main };
ddiff --git a/scripts/lib/db-ops.js b/scripts/lib/db-ops.js
nnew file mode 100644
iindex 00000000..8d972412
---- /dev/null
++++ b/scripts/lib/db-ops.js
@@@ -0,0 +1,66 @@
++'use strict';
++/**
++ * Shared by the operator scripts that change network.db (scripts/hash-refresh-tokens.js,
++ * scripts/secrets-out-of-db.js):
++ *
++ * dbPath(args) --db, else $DB_PATH, else data/network.db (relative to the repo root)
++ * dropToOwnerOf(file) when run as root: become the file's owner (uid/gid) so nothing the script
++ * writes (the -wal/-shm files, a backup) ends up owned by root; a no-op otherwise
++ * backupTo(db, target) sqlite online backup to a NEW file, owner-only (0600), verified with
++ * quick_check; refuses an existing target
++ */
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..', '..');
++
++function dbPath(args = {}, env = process.env) {
++ return path.resolve(ROOT, args.db || env.DB_PATH || path.join('data', 'network.db'));
++}
++
++function dropToOwnerOf(file, log = console.log) {
++ if (typeof process.getuid !== 'function' || process.getuid() !== 0) return false;
++ const st = fs.statSync(file);
++ if (st.uid === 0) return false;
++ process.setgid(st.gid);
++ process.setuid(st.uid);
++ log(`running as uid ${st.uid} (owner of ${path.basename(file)})`);
++ return true;
++}
++
++async function backupTo(db, target, { Database = require('better-sqlite3'), expect = null } = {}) {
++ const file = path.resolve(ROOT, target);
++ if (fs.existsSync(file)) throw new Error(`backup target ${file} already exists; choose a new path`);
++ const umask = process.umask(0o077);
++ try { await db.backup(file); } finally { process.umask(umask); }
++ fs.chmodSync(file, 0o600);
++ const check = new Database(file, { readonly: true });
++ try {
++ const ok = check.pragma('quick_check', { simple: true });
++ if (ok !== 'ok') throw new Error(`backup check failed: quick_check=${ok}`);
++ if (expect) {
++ const n = check.prepare(`SELECT COUNT(*) AS n FROM ${expect.table}`).get().n;
++ if (n < expect.rows) throw new Error(`backup check failed: ${expect.table} has ${n} rows, expected ${expect.rows}`);
++ }
++ } finally { check.close(); }
++ return file;
++}
++
++/** --flag / --key value parsing shared by the scripts. */
++function parseArgs(argv, { flags = [], values = [] } = {}) {
++ const out = { _: [] };
++ for (let i = 0; i < argv.length; i++) {
++ const a = argv[i];
++ const name = a.replace(/^--/, '');
++ if (flags.includes(name)) out[name] = true;
++ else if (values.includes(name)) {
++ const v = argv[++i];
++ if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`);
++ out[name] = v;
++ } else if (a === '--help' || a === '-h') out.help = true;
++ else throw new Error(`unknown argument ${a}`);
++ }
++ return out;
++}
++
++module.exports = { ROOT, dbPath, dropToOwnerOf, backupTo, parseArgs };
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 5ab1963f..0ebfb897 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -12,6 +12,7 @@ const subjects = require('../identity/subjects');
const principals = require('../identity/principals');
const devTokens = require('../developer/tokens');
const oidc = require('./oidc');
++const refreshTokens = require('./refresh-tokens');
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
@@@ -350,19 +351,13 @@ function handleFedcmAssertionGrant(db, config, req, res, client, assertion) {
function handleRefreshGrant(db, config, req, res, client, refreshToken) {
if (!refreshToken) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
-- const stored = db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(refreshToken);
-- if (!stored) return res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid refresh token' });
-- if (stored.revoked) return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token revoked' });
-- if (stored.client_id !== client.client_id) return res.status(400).json({ error: 'invalid_grant', error_description: 'Client mismatch' });
--
-- const now = new Date();
-- const expiresAt = new Date(stored.expires_at + (stored.expires_at.includes('Z') ? '' : 'Z'));
-- if (now > expiresAt) return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token expired' });
--
-- // Revoke old refresh token (rotation), atomically: two concurrent uses cannot both succeed
-- if (db.prepare('UPDATE oauth_tokens SET revoked = 1 WHERE id = ? AND revoked = 0').run(stored.id).changes !== 1) {
-- return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token revoked' });
++ // Hashed lookup, rotation, and family revocation on reuse (server/auth/refresh-tokens.js).
++ const r = refreshTokens.rotate(db, refreshToken, client.client_id);
++ if (!r.ok) {
++ if (r.reuse) console.warn(`[OAuth] refresh token reuse (${client.client_id}): family revoked`);
++ return res.status(400).json({ error: r.error, error_description: r.description });
}
++ const stored = r.row;
// Get user
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(stored.user_id);
@@@ -375,8 +370,8 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {
}
}
-- // Issue new token pair
-- const { accessToken, refreshToken: newRefresh } = issueTokenPair(db, config, req, user, client);
++ // Issue new token pair: the next generation of the same family
++ const { accessToken, refreshToken: newRefresh } = issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation });
recordLinkedService(db, user, client);
res.json({
@@@ -388,7 +383,7 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {
});
}
--function issueTokenPair(db, config, req, user, client) {
++function issueTokenPair(db, config, req, user, client, family = {}) {
const privateKey = req.app.locals.privateKey;
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
@@@ -412,12 +407,8 @@ function issueTokenPair(db, config, req, user, client) {
}
);
-- const refreshTokenValue = crypto.randomBytes(48).toString('hex');
-- const refreshExpires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days
-- db.prepare(`
-- INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at)
-- VALUES (?, ?, ?, 'profile theme', ?)
-- `).run(refreshTokenValue, client.client_id, user.id, refreshExpires);
++ // 30 days; only its SHA-256 is stored. A sign-in starts a family; a refresh continues it.
++ const refreshTokenValue = refreshTokens.issue(db, { clientId: client.client_id, userId: user.id, familyId: family.familyId, generation: family.generation || 0 });
return { accessToken, refreshToken: refreshTokenValue };
}
ddiff --git a/server/auth/refresh-tokens.js b/server/auth/refresh-tokens.js
nnew file mode 100644
iindex 00000000..d656d1fc
---- /dev/null
++++ b/server/auth/refresh-tokens.js
@@@ -0,0 +1,125 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// OAuth refresh tokens (roadmap §18.2(2)): stored as a hash, rotated on every use, one family per
++// sign-in, and a reused token revokes its whole family.
++//
++// oauth_tokens.token 'sha256:<hex>' of the token; the token itself is never stored. Tokens
++// are 48 random bytes (96 hex characters), so an unsalted SHA-256 is enough.
++// oauth_tokens.family_id every token rotated from the same sign-in shares it
++// oauth_tokens.generation 0 for the token a sign-in issued, +1 per rotation
++// oauth_tokens.revoked_reason 'rotated' (replaced by the next generation) or 'reuse' (the family
++// was revoked because a rotated token was presented again)
++//
++// Reuse: presenting a token that was already rotated means two parties hold it (one of them is not the
++// client), so every live token of the family is revoked and both have to sign in again. A token
++// presented again within REUSE_GRACE_S of its own rotation is refused WITHOUT revoking the family:
++// that is two tabs of one browser refreshing at once through Live's cookie, not a replay.
++//
++// Rows written before this module existed hold the raw token. They keep working: find() matches a raw
++// row too and hashes it on first use. scripts/hash-refresh-tokens.js hashes the rest in place (one-way,
++// batched, with a backup), so no raw token has to wait for its next use. ensureSchema() at boot only adds
++// columns; it changes no data.
++// ═══════════════════════════════════════════════════════════════
++const crypto = require('crypto');
++
++const PREFIX = 'sha256:';
++const RAW_RE = /^[0-9a-f]{96}$/;
++const REUSE_GRACE_S = 10;
++const TTL_MS = 30 * 24 * 60 * 60 * 1000;
++
++const hash = (token) => PREFIX + crypto.createHash('sha256').update(String(token)).digest('hex');
++const newFamily = () => `fam_${crypto.randomBytes(12).toString('hex')}`;
++const at = (v) => (v ? new Date(String(v).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(v)) ? '' : 'Z')).getTime() : NaN);
++
++/** Columns and index (boot, idempotent). No data is changed here. */
++function ensureSchema(db) {
++ const cols = new Set(db.prepare('PRAGMA table_info(oauth_tokens)').all().map(c => c.name));
++ if (!cols.has('family_id')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN family_id TEXT');
++ if (!cols.has('generation')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN generation INTEGER NOT NULL DEFAULT 0');
++ if (!cols.has('revoked_reason')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN revoked_reason TEXT');
++ if (!cols.has('revoked_at')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN revoked_at DATETIME');
++ db.exec('CREATE INDEX IF NOT EXISTS idx_oauth_tokens_family ON oauth_tokens(family_id)');
++}
++
++const LEGACY_WHERE = `substr(token, 1, ${PREFIX.length}) <> '${PREFIX}'`;
++
++/** How many rows still hold a raw token. */
++function countLegacy(db) {
++ return db.prepare(`SELECT COUNT(*) AS n FROM oauth_tokens WHERE ${LEGACY_WHERE}`).get().n;
++}
++
++/**
++ * Replace every raw token by its SHA-256, in place (same row), one-way; a row without a family becomes
++ * its own. Batched transactions; idempotent. Returns the number of rows hashed.
++ */
++function hashLegacy(db, { batch = 5000 } = {}) {
++ const pick = db.prepare(`SELECT id, token FROM oauth_tokens WHERE ${LEGACY_WHERE} LIMIT ?`);
++ const up = db.prepare('UPDATE oauth_tokens SET token = ?, family_id = COALESCE(family_id, ?) WHERE id = ? AND token = ?');
++ let n = 0;
++ for (;;) {
++ const rows = pick.all(batch);
++ if (!rows.length) break;
++ db.transaction(() => { for (const r of rows) n += up.run(hash(r.token), `fam_legacy_${r.id}`, r.id, r.token).changes; })();
++ }
++ return n;
++}
++
++/** Store a new refresh token; returns the token (the only time it exists in clear). */
++function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, now = Date.now() }) {
++ const token = crypto.randomBytes(48).toString('hex');
++ db.prepare(`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation)
++ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(hash(token), clientId, userId, scope, new Date(now + TTL_MS).toISOString(), familyId || newFamily(), generation);
++ return token;
++}
++
++/** The stored row for a presented token, or null. Never matches a stored hash presented as a token. */
++function find(db, presented) {
++ const raw = String(presented || '');
++ if (!RAW_RE.test(raw)) return null;
++ const row = db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(hash(raw));
++ if (row) return row;
++ // A row still holding the raw token (written before this change, not yet hashed by
++ // scripts/hash-refresh-tokens.js): hash it now, then use it.
++ const legacy = db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(raw);
++ if (!legacy) return null;
++ db.prepare('UPDATE oauth_tokens SET token = ?, family_id = COALESCE(family_id, ?) WHERE id = ? AND token = ?').run(hash(raw), `fam_legacy_${legacy.id}`, legacy.id, raw);
++ return db.prepare('SELECT * FROM oauth_tokens WHERE id = ?').get(legacy.id);
++}
++
++/** Revoke every live token of a family; returns how many. */
++function revokeFamily(db, familyId, reason = 'reuse') {
++ if (!familyId) return 0;
++ return db.prepare("UPDATE oauth_tokens SET revoked = 1, revoked_reason = ?, revoked_at = CURRENT_TIMESTAMP WHERE family_id = ? AND revoked = 0").run(reason, familyId).changes;
++}
++
++function audit(db, row, details) {
++ try {
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(row.user_id, 'oauth_refresh_reuse', JSON.stringify({ client_id: row.client_id, family_id: row.family_id, ...details }));
++ } catch { /* audit is best effort */ }
++}
++
++/**
++ * Validate a presented refresh token for `clientId` and rotate it. Returns
++ * { ok: true, row, familyId, generation } the caller issues generation `generation` in `familyId`
++ * { ok: false, error: 'invalid_grant', description, reuse? }
++ * The old token is revoked ('rotated') atomically: of two concurrent uses exactly one gets ok.
++ */
++function rotate(db, presented, clientId, { now = Date.now() } = {}) {
++ const row = find(db, presented);
++ const bad = (description, extra = {}) => ({ ok: false, error: 'invalid_grant', description, ...extra });
++ if (!row) return bad('Invalid refresh token');
++ if (row.client_id !== clientId) return bad('Client mismatch');
++ if (row.revoked) {
++ if (row.revoked_reason === 'rotated' && now - at(row.revoked_at) <= REUSE_GRACE_S * 1000) return bad('Refresh token already used');
++ const current = db.prepare('SELECT MAX(generation) AS g FROM oauth_tokens WHERE family_id = ?').get(row.family_id).g;
++ const n = revokeFamily(db, row.family_id, 'reuse');
++ if (row.revoked_reason === 'rotated' || n) audit(db, row, { generation_presented: row.generation, generation_current: current, revoked: n });
++ return bad('Refresh token revoked', { reuse: row.revoked_reason === 'rotated' });
++ }
++ if (now > at(row.expires_at)) return bad('Refresh token expired');
++ const won = db.prepare("UPDATE oauth_tokens SET revoked = 1, revoked_reason = 'rotated', revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked = 0").run(row.id).changes === 1;
++ if (!won) return bad('Refresh token already used'); // a concurrent use of the same token got it
++ return { ok: true, row, familyId: row.family_id || `fam_legacy_${row.id}`, generation: (row.generation || 0) + 1 };
++}
++
++module.exports = { ensureSchema, countLegacy, hashLegacy, issue, find, rotate, revokeFamily, hash, PREFIX, REUSE_GRACE_S, TTL_MS };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 31c0a186..77f381a5 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -427,6 +427,11 @@ function initDb(dbPath) {
}
}
++ // ── Refresh tokens: hash-only storage, families, generations (server/auth/refresh-tokens.js) ──
++ // Columns only. Rows still holding a raw token work and are hashed on use; the rest are hashed in
++ // place by scripts/hash-refresh-tokens.js (the operator runs it, with a backup).
++ require('../auth/refresh-tokens').ensureSchema(db);
++
// ── Email verification tokens + preference default semantics ──
try {
db.exec(`
ddiff --git a/test/refresh-tokens.test.js b/test/refresh-tokens.test.js
nnew file mode 100644
iindex 00000000..dcc27bd9
---- /dev/null
++++ b/test/refresh-tokens.test.js
@@@ -0,0 +1,201 @@
++'use strict';
++// Refresh tokens (roadmap §18.2(2), server/auth/refresh-tokens.js): only a hash is stored, each rotation
++// is the next generation of the sign-in's family, a reused (already rotated) token revokes the whole
++// family, and rows written with the raw token before this change keep working (hashed on first use)
++// until scripts/hash-refresh-tokens.js hashes the rest in place, one-way (with a backup and a rollback).
++// node test/refresh-tokens.test.js
++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 jwt = require('jsonwebtoken');
++const Database = require('better-sqlite3');
++const { initDb } = require('../server/db/database');
++const refreshTokens = require('../server/auth/refresh-tokens');
++
++const quiet = (fn) => { const log = console.log; console.log = () => {}; try { return fn(); } finally { console.log = log; } };
++
++(async () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-refresh-'));
++ const dbPath = path.join(dir, 'network.db');
++
++ // ── A database as the old code left it: raw refresh tokens, no family or generation ──
++ {
++ const old = new Database(dbPath);
++ old.exec(`CREATE TABLE oauth_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT UNIQUE NOT NULL, client_id TEXT NOT NULL,
++ user_id INTEGER NOT NULL, scope TEXT DEFAULT 'profile theme', expires_at DATETIME NOT NULL, revoked INTEGER DEFAULT 0,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`);
++ old.close();
++ }
++ const RAW_LIVE = crypto.randomBytes(48).toString('hex');
++ const RAW_ROTATED = crypto.randomBytes(48).toString('hex');
++ {
++ const old = new Database(dbPath);
++ const ins = old.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, expires_at, revoked) VALUES (?, ?, ?, ?, ?)');
++ ins.run(RAW_LIVE, 'live', 7, new Date(Date.now() + 86400e3).toISOString(), 0);
++ ins.run(RAW_ROTATED, 'live', 7, new Date(Date.now() + 86400e3).toISOString(), 1);
++ old.close();
++ }
++
++ // Boot: initDb only adds the columns; the raw rows are still raw (and still work, below).
++ const db = quiet(() => initDb(dbPath));
++ let rows = db.prepare('SELECT * FROM oauth_tokens ORDER BY id').all();
++ assert.strictEqual(rows.length, 2);
++ assert.ok('family_id' in rows[0] && 'generation' in rows[0] && 'revoked_reason' in rows[0], 'boot adds the columns');
++ assert.strictEqual(rows[0].token, RAW_LIVE, 'boot changes no data');
++
++ // ── The operator script: dry run, --apply needs a backup, one-way hashing in place, rollback ──
++ const script = require('../scripts/hash-refresh-tokens');
++ const out = [];
++ const run = (...argv) => script.main(['--db', dbPath, ...argv], (m) => out.push(m));
++ const EXTRA = crypto.randomBytes(48).toString('hex');
++ db.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, expires_at) VALUES (?, ?, ?, ?)').run(EXTRA, 'live', 7, new Date(Date.now() + 86400e3).toISOString());
++ assert.strictEqual(await run(), 0);
++ assert.ok(out.join('\n').includes('still stored raw 3'), out.join('\n'));
++ assert.strictEqual(refreshTokens.countLegacy(db), 3, 'the dry run changes nothing');
++ assert.strictEqual(await run('--apply'), 2, '--apply refuses without --backup');
++ const backup = path.join(dir, 'pre-hash.db');
++ assert.strictEqual(await run('--apply', '--backup', backup), 0, out.join('\n'));
++ assert.strictEqual(fs.statSync(backup).mode & 0o777, 0o600, 'the backup is owner-only');
++ assert.strictEqual(await run('--apply', '--backup', backup), 0, 'nothing left to hash: no backup needed');
++ rows = db.prepare('SELECT * FROM oauth_tokens ORDER BY id').all();
++ for (const r of rows) {
++ assert.ok(r.token.startsWith('sha256:') && r.token.length === 'sha256:'.length + 64, 'every stored token is a SHA-256');
++ assert.ok(r.family_id && r.generation === 0, 'legacy rows get a family and generation 0');
++ }
++ assert.strictEqual(rows[0].id, 1);
++ assert.strictEqual(rows[0].token, refreshTokens.hash(RAW_LIVE), 'hashed in place (same row, same id)');
++ const dump = JSON.stringify(db.prepare('SELECT * FROM oauth_tokens').all());
++ assert.ok(!dump.includes(RAW_LIVE) && !dump.includes(RAW_ROTATED) && !dump.includes(EXTRA), 'no raw token is left: one-way');
++ assert.ok(!out.join('\n').includes(RAW_LIVE), 'the script never prints a token');
++ // Rollback (for a return to the pre-hashing release): dry run, then the raw values come back.
++ out.length = 0;
++ assert.strictEqual(await run('--restore-from', backup), 0);
++ assert.ok(out.join('\n').includes('3 row(s) here still hold their hash'), out.join('\n'));
++ assert.strictEqual(refreshTokens.countLegacy(db), 0, 'the rollback dry run changes nothing');
++ assert.strictEqual(await run('--restore-from', backup, '--apply'), 0);
++ assert.strictEqual(db.prepare('SELECT token FROM oauth_tokens WHERE id = 1').get().token, RAW_LIVE, 'rolled back');
++ assert.strictEqual(await run('--apply', '--backup', path.join(dir, 'pre-hash-2.db')), 0, 'and forward again');
++ assert.strictEqual(refreshTokens.countLegacy(db), 0);
++ db.prepare('DELETE FROM oauth_tokens WHERE token = ?').run(refreshTokens.hash(EXTRA));
++ // Put one raw row back as the old code would have left it, to show the server still takes it.
++ db.prepare('UPDATE oauth_tokens SET token = ? WHERE id = 1').run(RAW_LIVE);
++
++ db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
++ db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();
++ db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();
++
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ const ISSUER = 'https://openvibe.network';
++ const app = express();
++ app.use(express.json());
++ app.locals.db = db;
++ app.locals.config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h', refreshTokenExpiry: '30d' } };
++ app.locals.privateKey = keys.privateKey;
++ app.locals.publicKey = keys.publicKey;
++ app.use('/oauth', require('../server/auth/oauth-routes'));
++ const server = http.createServer(app);
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const post = (p, body) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(async r => ({ status: r.status, body: await r.json() }));
++ const refresh = (token, client = 'live', secret = 'live-secret') => post('/oauth/token', { grant_type: 'refresh_token', client_id: client, client_secret: secret, refresh_token: token });
++ const REDIRECT = 'https://openvibe.live/api/auth/callback';
++ const userToken = jwt.sign({ sub: 7, id: 7, username: 'viewer' }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '5m' });
++ const signIn = async () => {
++ const c = await post('/oauth/confirm', { token: userToken, client_id: 'live', redirect_uri: REDIRECT });
++ const t = await post('/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: 'live-secret', code: new URL(c.body.redirect).searchParams.get('code'), redirect_uri: REDIRECT });
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ return t.body.refresh_token;
++ };
++ const rowOf = (token) => db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(refreshTokens.hash(token));
++ const age = (token, seconds) => db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now', ?) WHERE token = ?").run(`-${seconds} seconds`, refreshTokens.hash(token));
++
++ // ── A migrated (pre-change) token still refreshes, once ──
++ let r = await refresh(RAW_LIVE);
++ assert.strictEqual(r.status, 200, `a token written before the change still works: ${JSON.stringify(r.body)}`);
++ const legacyFamily = rowOf(RAW_LIVE).family_id;
++ assert.strictEqual(rowOf(r.body.refresh_token).family_id, legacyFamily, 'its successor continues the legacy family');
++ assert.strictEqual(rowOf(r.body.refresh_token).generation, 1);
++ r = await refresh(RAW_ROTATED);
++ assert.strictEqual(r.status, 400, 'a token the old code had already rotated stays dead');
++
++ // ── New sign-ins: hash only, family, generations ──
++ const t0 = await signIn();
++ assert.match(t0, /^[0-9a-f]{96}$/);
++ const row0 = rowOf(t0);
++ assert.ok(row0, 'found by its hash');
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens WHERE token = ?').get(t0).n, 0, 'the token itself is not stored');
++ assert.strictEqual(row0.generation, 0);
++ assert.match(row0.family_id, /^fam_[0-9a-f]{24}$/);
++ r = await refresh(t0);
++ assert.strictEqual(r.status, 200);
++ const t1 = r.body.refresh_token;
++ assert.strictEqual(rowOf(t1).family_id, row0.family_id, 'rotation stays in the family');
++ assert.strictEqual(rowOf(t1).generation, 1, 'generation counts rotations');
++ assert.strictEqual(rowOf(t0).revoked_reason, 'rotated');
++ r = await refresh(t1);
++ const t2 = r.body.refresh_token;
++ assert.strictEqual(rowOf(t2).generation, 2);
++
++ // The stored hash is not a token, and a malformed token is refused without a lookup.
++ r = await refresh(rowOf(t2).token);
++ assert.strictEqual(r.status, 400, 'presenting the stored hash does not work');
++ r = await refresh(rowOf(t2).token.slice('sha256:'.length));
++ assert.strictEqual(r.status, 400, 'nor its hex');
++
++ // Another client cannot use (or burn) the family.
++ r = await refresh(t2, 'tools', 'tools-secret');
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(rowOf(t2).revoked, 0, 'a client mismatch revokes nothing');
++
++ // ── Reuse within the grace window (two tabs at once): refused, family intact ──
++ r = await refresh(t1);
++ assert.strictEqual(r.status, 400, 'a just-rotated token is single-use');
++ assert.strictEqual(rowOf(t2).revoked, 0, 'a concurrent double refresh does not revoke the family');
++
++ // ── Reuse after the grace window: the whole family is revoked ──
++ age(t1, refreshTokens.REUSE_GRACE_S + 5);
++ r = await refresh(t1);
++ assert.strictEqual(r.status, 400, 'a replayed rotated token is refused');
++ assert.strictEqual(rowOf(t2).revoked, 1, 'and its family is revoked: the latest generation dies too');
++ assert.strictEqual(rowOf(t2).revoked_reason, 'reuse');
++ r = await refresh(t2);
++ assert.strictEqual(r.status, 400, 'the latest token no longer refreshes');
++ const auditRow = db.prepare("SELECT * FROM audit_log WHERE action = 'oauth_refresh_reuse' ORDER BY id DESC").get();
++ assert.ok(auditRow, 'reuse is audited');
++ const details = JSON.parse(auditRow.details);
++ assert.strictEqual(details.family_id, row0.family_id);
++ assert.strictEqual(details.generation_presented, 1);
++ assert.strictEqual(details.generation_current, 2);
++ assert.ok(!auditRow.details.includes(t1) && !auditRow.details.includes(rowOf(t1).token), 'the audit carries no token or hash');
++ // Other families of the same account are untouched.
++ const other = await signIn();
++ r = await refresh(other);
++ assert.strictEqual(r.status, 200, 'a separate sign-in (family) keeps working');
++
++ // ── Concurrent use of one token: exactly one wins ──
++ const racer = await signIn();
++ const results = await Promise.all([1, 2, 3].map(() => refresh(racer)));
++ assert.strictEqual(results.filter(x => x.status === 200).length, 1, 'one of three concurrent refreshes wins');
++ const winner = results.find(x => x.status === 200).body.refresh_token;
++ assert.strictEqual((await refresh(winner)).status, 200, "the winner's token keeps working");
++
++ // ── A raw row that appears after boot (defensive path) is hashed on first use ──
++ const late = crypto.randomBytes(48).toString('hex');
++ db.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, expires_at) VALUES (?, ?, ?, ?)').run(late, 'live', 7, new Date(Date.now() + 86400e3).toISOString());
++ r = await refresh(late);
++ assert.strictEqual(r.status, 200, 'a raw row still works');
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens WHERE token = ?').get(late).n, 0, 'and is hashed on use');
++ assert.strictEqual(rowOf(late).revoked_reason, 'rotated');
++
++ // Expired tokens are refused.
++ const exp = await signIn();
++ db.prepare('UPDATE oauth_tokens SET expires_at = ? WHERE token = ?').run(new Date(Date.now() - 1000).toISOString(), refreshTokens.hash(exp));
++ assert.strictEqual((await refresh(exp)).status, 400, 'an expired refresh token is refused');
++
++ server.close();
++ console.log('refresh tokens: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });