Terminate deleted user sessions immediately

Drop the special case that deferred terminating the connection issuing
the delete, so deleting yourself behaves the same as revoke-token and
every session of the deleted user is closed before the handler returns.

Set tokenValidAfter to the current time in createUser instead of 0.
Deleting a user and recreating it under the same name left tokens issued
before the deletion valid, as the check is iat < tokenValidAfter. Doing
it in createUser covers every path that makes a user. A token signed in
the same second as creation is still accepted, so create-admin and the
other production paths sign after the user exists and are unaffected.

The integration harness signed the control token at import time, before
bootstrap create-admin created the user. Sign it in the root before hook
instead and reuse it in link.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5 committed 2026-09-13 13:31:09 +00:00
1 parent fb71ee7282
commit 1da7c1da67
6 files changed
+30 -16

No files matched your search

+1 -7
View File
@@ -1071,13 +1071,7 @@ export default class ControlConnection extends BaseConnection {
this._controller.users.deleteUser(user);
for (let controlConnection of this._controller.wsServer.controlConnections.values()) {
if (controlConnection.user.id !== user.id) {
continue;
}
if (controlConnection === this) {
// Let the response go out before dropping our own session
setImmediate(() => controlConnection.connector.terminate());
} else {
if (controlConnection.user.id === user.id) {
controlConnection.connector.terminate();
}
}
+3 -1
View File
@@ -119,7 +119,9 @@ export default class UserManager {
roles.add(defaultRoleId);
}
const user = new User(this.records, this._controllerRoles, 0, name, roles);
// Reject tokens issued before the user existed, e.g. for a previous user by the same name
const tokenValidAfter = Math.floor(Date.now() / 1000);
const user = new User(this.records, this._controllerRoles, tokenValidAfter, name, roles);
this.records.set(user);
return user;
}
+1 -3
View File
@@ -135,12 +135,10 @@ describe("controller/src/ControlConnection", function() {
assert.equal(admin.connector.terminated, false);
});
it("terminates its own connection after the handler returns", async function() {
it("terminates its own connection when deleting itself", async function() {
const player = connect("player");
await deleteUser(player, "player");
assert.equal(player.connector.terminated, false);
await new Promise(resolve => setImmediate(resolve));
assert.equal(player.connector.terminated, true);
});
});
+17
View File
@@ -79,6 +79,23 @@ describe("controller/UserManager", function () {
const user = userManager.createUser("Eve");
assert(user.roleIds.has(1));
});
it("should set tokenValidAfter to the current time", function () {
const before = Math.floor(Date.now() / 1000);
const user = userManager.createUser("Mallory");
assert(user.tokenValidAfter >= before, "tokenValidAfter predates the call");
assert(user.tokenValidAfter <= Math.floor(Date.now() / 1000), "tokenValidAfter is in the future");
});
it("should invalidate tokens issued before a user of the same name was recreated", function () {
const user = userManager.createUser("Niaj");
const oldTokenIat = user.tokenValidAfter - 60;
userManager.deleteUser(user);
// WsServer.registerControl rejects the token when iat < tokenValidAfter
const recreated = userManager.createUser("Niaj");
assert(oldTokenIat < recreated.tokenValidAfter, "token from before the deletion is still valid");
});
});
describe(".getOrCreateUser()", function () {
+3 -1
View File
@@ -191,7 +191,8 @@ let haveFactorioInstall;
const baseHostConfig = loadJSON("config-host.json");
export let url = "https://localhost:4443/";
export let controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64"));
// Signed in the root before hook, as the user has to exist before the token is issued
export let controlToken;
export let instancesDir = path.join("temp", "test", "instances");
export let modsDir = path.join("temp", "test", "mods");
export let databaseDir = path.join("temp", "test", "database");
@@ -373,6 +374,7 @@ before(async function() {
console.log("Bootstrapping");
await execController("bootstrap create-admin test");
await execController("bootstrap create-ctl-config test");
controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64"));
controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/);
+5 -4
View File
@@ -2,10 +2,11 @@ import assert from "node:assert/strict";
import events from "node:events";
import jwt from "jsonwebtoken";
import { TestControlConnector, TestHostConnector, TestControl, get, exec, url, slowTest } from "./index.js";
import {
TestControlConnector, TestHostConnector, TestControl, get, exec, url, controlToken, slowTest,
} from "./index.js";
import { ConnectionClosed, ProtocolError, PolicyViolation, AuthenticationFailed } from "@clusterio/lib";
let token = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64"));
let tokenHost = jwt.sign({ aud: "host", host: 0 }, Buffer.from("TestSecretDoNotUse", "base64"));
describe("Integration of lib/link", function() {
@@ -14,7 +15,7 @@ describe("Integration of lib/link", function() {
let hostConnector;
beforeEach(async function() {
controlConnector = new TestControlConnector(url, 0.2);
controlConnector.token = token;
controlConnector.token = controlToken;
control = new TestControl(controlConnector, false);
hostConnector = new TestHostConnector(url, 0.2);
hostConnector.token = tokenHost;
@@ -100,7 +101,7 @@ describe("Integration of lib/link", function() {
});
it("should refuse connection with incorrect audience", async function() {
hostConnector.token = token;
hostConnector.token = controlToken;
await assert.rejects(
hostConnector.connect(),
new Error("Authentication failed: jwt audience invalid. expected: host or slave")