openvibers/openvibe.network Commits
A merged account has no tokens of its own (ADR-029, found in the first production merge). Revocation has second precision, so the folded-in account's fresh sign-in token, minted in the same second as the merge, still answered /me for up to an hour. verifySession now refuses any token of an account with merged_into (401 Account merged), and a merge revokes strictly: token_valid_after is one second ahead, so other services, which compare iat with the announced valid_after, refuse a same-second token too (revokeTokens gains strict). test/account-merge.test.js: a token minted in the merge's second is refused.
Changes
@@@ -43,12 +43,14 @@ function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {* @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 } = {}) {++// strict: the cutoff is one second ahead, so a token minted in this same second is refused too (an account merge:++// the folded-in account's fresh sign-in token must not outlive the merge).++function revokeTokens(db, userId, { reason, actor, ctx, strict = false } = {}) {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(strict ? "UPDATE users SET token_valid_after = datetime(CURRENT_TIMESTAMP, '+1 second') WHERE id = ?" : '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;@@@ -39,6 +39,8 @@ function verifySession(token, { db, publicKey, config }) {const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);if (!user) return { error: 'User not found', status: 401 };if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };++ // An account merged into another (ADR-029) has no tokens of its own any more; signing in lands on the survivor.++ if (user.merged_into) return { error: 'Account merged', status: 401, merged: true };if (user.token_valid_after) {const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();if (decoded.iat * 1000 < validAfter) return { error: 'Token revoked', status: 401 };@@@ -220,7 +220,7 @@ function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, r// The folded-in account: merged, its tokens revoked and announced (network.user.token_valid_after, reason// account_merged: sites close its sockets). Its sessions already belong to the survivor.db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(intoUser.id, fromUser.id);-- revocation.revokeTokens(db, fromUser.id, { reason: 'account_merged', actor: { type: 'user', id: actor } });++ revocation.revokeTokens(db, fromUser.id, { reason: 'account_merged', actor: { type: 'user', id: actor }, strict: true });db.prepare(`INSERT INTO account_merges (id, from_subject, into_subject, from_user_id, into_user_id, initiated_by, actor_subject, reason, moved, pre_state, merged_at, split_until)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(mergeId, from, into, fromUser.id, intoUser.id, initiatedBy, actor, reason, JSON.stringify({ ...moved, dropped }), JSON.stringify(pre), mergedAt, splitUntil);@@@ -91,6 +91,7 @@ const server = http.createServer(app);db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en'), (?, 'pt')").run(carol.id, carol2.id);db.prepare("INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id) VALUES ('live', 'user', '70', ?)").run(carol2.subject_id);const oldCarol2 = tok(carol2);++ const sameSecond = jwt.sign({ sub: carol2.id, id: carol2.id, subject_id: carol2.subject_id, username: carol2.username, role: 'user', auth_time: Math.floor(Date.now() / 1000) }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });// ── Refusals ──let r = await call('POST', '/api/v1/account/merge/intents', tok(carol));@@@ -153,6 +154,8 @@ const server = http.createServer(app);const revoked = events('network.user.token_valid_after').filter((e) => e.subject.id === carol2.subject_id);assert.deepStrictEqual(revoked.map((e) => e.payload.reason), ['account_merged']);assert.strictEqual((await call('GET', '/api/auth/me', oldCarol2)).status, 401, "the folded-in account's old token is refused");++ assert.strictEqual((await call('GET', '/api/auth/me', sameSecond)).status, 401, 'even one minted in the second of the merge (strict cutoff; merged accounts have no tokens)');++ assert.ok(Date.parse(revoked[0].payload.valid_after) > Date.parse(merged[0].payload.merged_at) - 1000, 'services get a cutoff past the merge second');// Signing in to the folded-in account signs in to the survivor.r = await call('POST', '/api/auth/login', null, { username: 'carol_old', password: 'secret12' });assert.deepStrictEqual([r.status, r.body.user && r.body.user.username, r.body.merged_into], [200, 'carol', 'carol']);
@@ -43,12 +43,14 @@ function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {
@@ -43,12 +43,14 @@ function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {
* @param {{ reason: string, actor?: object, ctx?: object }} opts actor defaults to the person themself
* @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
* @returns {{ validAfter: string, event: object|null }} event is null for an account with no subject
*/
*/
-function revokeTokens(db, userId, { reason, actor, ctx } = {}) {
+// strict: the cutoff is one second ahead, so a token minted in this same second is refused too (an account merge:
+// the folded-in account's fresh sign-in token must not outlive the merge).
+function revokeTokens(db, userId, { reason, actor, ctx, strict = false } = {}) {
if (!REASONS.includes(reason)) throw new Error(`revocation: unknown reason ${reason}`);
if (!REASONS.includes(reason)) throw new Error(`revocation: unknown reason ${reason}`);
return db.transaction(() => {
return db.transaction(() => {
const user = db.prepare('SELECT id, subject_id FROM users WHERE id = ?').get(userId);
const user = db.prepare('SELECT id, subject_id FROM users WHERE id = ?').get(userId);
if (!user) throw new Error(`revocation: no user ${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(strict ? "UPDATE users SET token_valid_after = datetime(CURRENT_TIMESTAMP, '+1 second') WHERE id = ?" : '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);
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);
const validAfter = iso(db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id).t);
let event = null;
let event = null;
@@ -39,6 +39,8 @@ function verifySession(token, { db, publicKey, config }) {
@@ -39,6 +39,8 @@ function verifySession(token, { db, publicKey, config }) {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
if (!user) return { error: 'User not found', status: 401 };
if (!user) return { error: 'User not found', status: 401 };
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
+ // An account merged into another (ADR-029) has no tokens of its own any more; signing in lands on the survivor.
+ if (user.merged_into) return { error: 'Account merged', status: 401, merged: true };
if (user.token_valid_after) {
if (user.token_valid_after) {
const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();
const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();
if (decoded.iat * 1000 < validAfter) return { error: 'Token revoked', status: 401 };
if (decoded.iat * 1000 < validAfter) return { error: 'Token revoked', status: 401 };
@@ -220,7 +220,7 @@ function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, r
@@ -220,7 +220,7 @@ function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, r
// The folded-in account: merged, its tokens revoked and announced (network.user.token_valid_after, reason
// The folded-in account: merged, its tokens revoked and announced (network.user.token_valid_after, reason
// account_merged: sites close its sockets). Its sessions already belong to the survivor.
// account_merged: sites close its sockets). Its sessions already belong to the survivor.
db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(intoUser.id, fromUser.id);
db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(intoUser.id, fromUser.id);
- revocation.revokeTokens(db, fromUser.id, { reason: 'account_merged', actor: { type: 'user', id: actor } });
+ revocation.revokeTokens(db, fromUser.id, { reason: 'account_merged', actor: { type: 'user', id: actor }, strict: true });
db.prepare(`INSERT INTO account_merges (id, from_subject, into_subject, from_user_id, into_user_id, initiated_by, actor_subject, reason, moved, pre_state, merged_at, split_until)
db.prepare(`INSERT INTO account_merges (id, from_subject, into_subject, from_user_id, into_user_id, initiated_by, actor_subject, reason, moved, pre_state, merged_at, split_until)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(mergeId, from, into, fromUser.id, intoUser.id, initiatedBy, actor, reason, JSON.stringify({ ...moved, dropped }), JSON.stringify(pre), mergedAt, splitUntil);
.run(mergeId, from, into, fromUser.id, intoUser.id, initiatedBy, actor, reason, JSON.stringify({ ...moved, dropped }), JSON.stringify(pre), mergedAt, splitUntil);
@@ -91,6 +91,7 @@ const server = http.createServer(app);
@@ -91,6 +91,7 @@ const server = http.createServer(app);
db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en'), (?, 'pt')").run(carol.id, carol2.id);
db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en'), (?, 'pt')").run(carol.id, carol2.id);
db.prepare("INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id) VALUES ('live', 'user', '70', ?)").run(carol2.subject_id);
db.prepare("INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id) VALUES ('live', 'user', '70', ?)").run(carol2.subject_id);
const oldCarol2 = tok(carol2);
const oldCarol2 = tok(carol2);
+ const sameSecond = jwt.sign({ sub: carol2.id, id: carol2.id, subject_id: carol2.subject_id, username: carol2.username, role: 'user', auth_time: Math.floor(Date.now() / 1000) }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
// ── Refusals ──
// ── Refusals ──
let r = await call('POST', '/api/v1/account/merge/intents', tok(carol));
let r = await call('POST', '/api/v1/account/merge/intents', tok(carol));
@@ -153,6 +154,8 @@ const server = http.createServer(app);
@@ -153,6 +154,8 @@ const server = http.createServer(app);
const revoked = events('network.user.token_valid_after').filter((e) => e.subject.id === carol2.subject_id);
const revoked = events('network.user.token_valid_after').filter((e) => e.subject.id === carol2.subject_id);
assert.deepStrictEqual(revoked.map((e) => e.payload.reason), ['account_merged']);
assert.deepStrictEqual(revoked.map((e) => e.payload.reason), ['account_merged']);
assert.strictEqual((await call('GET', '/api/auth/me', oldCarol2)).status, 401, "the folded-in account's old token is refused");
assert.strictEqual((await call('GET', '/api/auth/me', oldCarol2)).status, 401, "the folded-in account's old token is refused");
+ assert.strictEqual((await call('GET', '/api/auth/me', sameSecond)).status, 401, 'even one minted in the second of the merge (strict cutoff; merged accounts have no tokens)');
+ assert.ok(Date.parse(revoked[0].payload.valid_after) > Date.parse(merged[0].payload.merged_at) - 1000, 'services get a cutoff past the merge second');
// Signing in to the folded-in account signs in to the survivor.
// Signing in to the folded-in account signs in to the survivor.
r = await call('POST', '/api/auth/login', null, { username: 'carol_old', password: 'secret12' });
r = await call('POST', '/api/auth/login', null, { username: 'carol_old', password: 'secret12' });
assert.deepStrictEqual([r.status, r.body.user && r.body.user.username, r.body.merged_into], [200, 'carol', 'carol']);
assert.deepStrictEqual([r.status, r.body.user && r.body.user.username, r.body.merged_into], [200, 'carol', 'carol']);