import assert from "node:assert/strict"; import { mock as nodeMock } from "node:test"; import jwt from "jsonwebtoken"; import * as mock from "../../../test/mock.js"; import * as controller from "../dist/node/controller.js"; import * as instancePlugin from "../dist/node/instance.js"; import { plugin as info } from "../dist/node/index.js"; import { FetchPlayerCodeRequest, SetVerifyCodeRequest, PlayerAuthServer } from "../dist/node/messages.js"; import { testRoundTripJsonSerialisable, testMatrix } from "../../../test/common.js"; import * as lib from "@clusterio/lib"; function postJSON(url, body) { return fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } describe("player_auth", function() { describe("messages.js", function() { describe("PlayerAuthServer", function() { it("should be round trip json serialisable", function() { testRoundTripJsonSerialisable(PlayerAuthServer, testMatrix( ["server1", "server2"], // name [undefined, "127.0.0.1", "10.0.0.1:1234"], // address [undefined, "1.1.0", "2.0.0"], // version )); }); }); describe("FetchPlayerCodeResponse", function() { it("should be round trip json serialisable", function() { testRoundTripJsonSerialisable(FetchPlayerCodeRequest.Response, testMatrix( ["abc123", "player-code"], // playerCode ["http://localhost", "https://example.com"], // controllerUrl )); }); }); describe("FetchPlayerCodeRequest", function() { it("should be round trip json serialisable", function() { testRoundTripJsonSerialisable(FetchPlayerCodeRequest, testMatrix( ["player1", "player2"], // player )); }); }); describe("SetVerifyCodeRequest", function() { it("should be round trip json serialisable", function() { testRoundTripJsonSerialisable(SetVerifyCodeRequest, testMatrix( ["player1", "player2"], // player ["code123", "verify456"], // verifyCode )); }); }); }); describe("controller.js", function() { describe("generateCode()", function() { it("should generate a code of the given length", async function() { let code = await controller._generateCode(20); assert.equal(typeof code, "string"); assert.equal(code.length, 20, "incorrect length"); }); it("should only use the easy to recognize letters in the alphabet", async function() { let chars = new Set(); for (let i = 0; i < 20; i++) { let code = await controller._generateCode(15); for (let char of code) { chars.add(char); } } for (let char of chars) { assert(/[A-Za-z0-9]/.test(char), `Contained non-alphanumeric letter ${char}`); assert(/[^ji1lI0oOQ2Z]/.test(char), `Contained banned letter ${char}`); } }); }); describe("entrypoint", function() { let mockController; let controllerUrl; let endpoint; let fetchPlayerCode; let setVerifyCode; let now; let dateNow; beforeEach(function() { // Keep code expiry and JWT timestamps independent of HTTP request timing. now = Date.now(); dateNow = nodeMock.method(Date, "now", () => now); }); afterEach(function() { dateNow.mock.restore(); }); // Adds a pending code for player through the request handlers async function addPlayer(player, { timeout = 1, verifyCode } = {}) { mockController.mockConfigEntries.set("player_auth.code_timeout", timeout); try { const { playerCode } = await fetchPlayerCode(new FetchPlayerCodeRequest(player)); if (verifyCode) { await setVerifyCode(new SetVerifyCodeRequest(player, verifyCode)); } return playerCode; } finally { mockController.mockConfigEntries.set("player_auth.code_timeout", 1); } } function signVerifyToken(playerCode, verifyCode) { return jwt.sign({ aud: "player_auth.verify_code", player_code: playerCode, verify_code: verifyCode, }, Buffer.from("TestSecretDoNotUse", "base64")); } before(async function() { mockController = await mock.loadControllerPlugin(controller.default, info); mockController.mockConfigEntries.set("player_auth.code_length", 10); mockController.mockConfigEntries.set("player_auth.code_timeout", 1); mockController.mockConfigEntries.set("player_auth.show_connect_address", true); fetchPlayerCode = mock.getHandler(mockController, FetchPlayerCodeRequest); setVerifyCode = mock.getHandler(mockController, SetVerifyCodeRequest); controllerUrl = await mockController.startServer(); }); after(async function() { if (mockController) { await mockController.stopServer(); } }); describe("/api/player_auth/servers", function() { function addInstance(id, status, load, name, version, gamePort, host=1, proxyPort=null) { mockController.instances.records.set(id, { config: { get(field) { if (field === "player_auth.load_plugin") { return load; } if (field === "factorio.settings") { return { name }; } if (field === "instance.id") { return id; } if (field === "instance.assigned_host") { return host; } if (field === "instance.player_proxy_port") { return proxyPort; } throw new Error(`field ${field} not implemented`); }, }, status, gamePort, factorioVersion: version, }); } it("should return a list of running servers with player_auth loaded", async function() { addInstance(1, "running", true, "running loaded", "1.1.1", 34197); addInstance(2, "stopped", true, "stopped loaded", "1.1.2", 34197); addInstance(3, "running", false, "running unloaded", "1.1.3", 34197); addInstance(4, "stopped", false, "stopped unloaded", "1.1.4", 34197); addInstance(5, "running", true, undefined, "1.1.5", 34197); addInstance(6, "running", true, "no port", "1.1.6", undefined); addInstance(7, "running", true, "no host", "1.1.7", 34197, null); addInstance(8, "running", true, "invalid host", "1.1.8", 34197, 2); addInstance(9, "running", true, "no version", undefined, 34197); mockController.hosts.set(1, { publicAddress: "127.0.0.1", }); const result = await fetch(`${controllerUrl}/api/player_auth/servers`); assert.deepEqual(await result.json(), [ { name: "running loaded", address: "127.0.0.1:34197", factorioVersion: "1.1.1" }, { name: "unnamed server", address: "127.0.0.1:34197", factorioVersion: "1.1.5" }, { name: "no port", address: "127.0.0.1", factorioVersion: "1.1.6" }, { name: "no version", address: "127.0.0.1:34197" }, ]); }); it("should list the player proxy once for instances behind it", async function() { for (const id of [...mockController.instances.records.keys()]) { mockController.instances.records.delete(id); } mockController.mockConfigEntries.set("controller.name", "Cluster"); mockController.mockConfigEntries.set( "controller.player_proxy_public_address", "play.example:34197" ); mockController.getPlayerProxyAddress = port => `play.example:${port ?? 34197}`; try { addInstance(1, "running", true, "behind proxy", "2.1.19", 34201); addInstance(2, "running", true, "also behind proxy", "2.1.19", 34202); addInstance(3, "running", true, "dedicated port", "2.1.19", 34203, 1, 34300); addInstance(4, "stopped", true, "stopped", "2.1.19", 34204, 1, 34301); const result = await fetch(`${controllerUrl}/api/player_auth/servers`); assert.deepEqual(await result.json(), [ { name: "Cluster", address: "play.example:34197", factorioVersion: "2.1.19" }, { name: "dedicated port", address: "play.example:34300", factorioVersion: "2.1.19" }, ]); } finally { mockController.mockConfigEntries.set("controller.player_proxy_public_address", null); } }); }); describe("/api/player_auth/player_code", function() { before(function() { endpoint = `${controllerUrl}/api/player_auth/player_code`; }); it("should return 400 on invalid json", async function() { const result = await postJSON(endpoint, "invalid"); assert.equal(result.status, 400); }); it("should return 400 on valid json which is not an object", async function() { for (let obj of [true, false, null, "string", 123, ["an", "array"]]) { const result = await postJSON(endpoint, obj); assert.equal(result.status, 400); } }); it("should return 400 if player_code is not a string", async function() { for (let obj of [true, false, null, 123, [], {}]) { const result = await postJSON(endpoint, { player_code: obj }); assert.equal(result.status, 400); } }); it("should return invalid player_code if the code is expired", async function() { const playerCode = await addPlayer("expired", { timeout: -1 }); const result = await postJSON(endpoint, { player_code: playerCode }); assert.deepEqual(await result.json(), { error: true, message: "invalid player_code" }); }); it("should return a verify code and token if code is valid", async function() { const playerCode = await addPlayer("valid"); const result = await postJSON(endpoint, { player_code: playerCode }); const body = await result.json(); assert.equal(typeof body.verify_code, "string"); assert.equal(typeof body.verify_token, "string"); }); }); describe("/api/player_auth/verify", function() { before(function() { endpoint = `${controllerUrl}/api/player_auth/verify`; }); it("should return 400 on invalid json", async function() { const result = await postJSON(endpoint, "invalid"); assert.equal(result.status, 400); }); it("should return 400 on valid json which is not an object", async function() { for (let obj of [true, false, null, "string", 123, ["an", "array"]]) { const result = await postJSON(endpoint, obj); assert.equal(result.status, 400); } }); it("should return 400 if player_code, verify_code or verify_token are not strings", async function() { for (let obj of [true, false, null, 123, [], {}]) { for (let field of ["player_code", "verify_code", "verify_token"]) { const result = await postJSON(endpoint, { player_code: "str", verify_code: "str", verify_token: "str", [field]: obj, }); assert.equal(result.status, 400); } } }); it("should return error if verify_token is not valid", async function() { async function verify(tokenParams, error) { const result = await postJSON(endpoint, { player_code: "player", verify_code: "verify", verify_token: jwt.sign({ aud: "player_auth.verify_code", player_code: "player", verify_code: "verify", ...tokenParams, }, Buffer.from(tokenParams.secret || "TestSecretDoNotUse", "base64")), }); assert.deepEqual((await result.json()).error, true); } await verify({ secret: "InvalidSecret" }); await verify({ verify_code: "bad" }); await verify({ player_code: "bad" }); await verify({ aud: "not_player_auth" }); }); it("should return invalid player_code if the code is expired", async function() { const playerCode = await addPlayer("expired", { timeout: -1 }); const result = await postJSON(endpoint, { player_code: playerCode, verify_code: "verify", verify_token: signVerifyToken(playerCode, "verify"), }); assert.deepEqual(await result.json(), { error: true, message: "invalid player_code" }); }); it("should return verified false if verify code has not yet been set", async function() { const playerCode = await addPlayer("unverified"); const result = await postJSON(endpoint, { player_code: playerCode, verify_code: "verify", verify_token: signVerifyToken(playerCode, "verify"), }); assert.deepEqual(await result.json(), { verified: false }); }); it("should return error if user is missing", async function() { const playerCode = await addPlayer("missing", { verifyCode: "verify" }); const result = await postJSON(endpoint, { player_code: playerCode, verify_code: "verify", verify_token: signVerifyToken(playerCode, "verify"), }); assert.deepEqual(await result.json(), { error: true, message: "invalid user" }); }); it("should return verified true with token if valid verification", async function() { const playerCode = await addPlayer("player", { verifyCode: "verify" }); const result = await postJSON(endpoint, { player_code: playerCode, verify_code: "verify", verify_token: signVerifyToken(playerCode, "verify"), }); const body = await result.json(); assert.equal(body.verified, true); assert.deepEqual(typeof body.token, "string", "missing token"); }); }); describe("FetchPlayerCodeRequest handler", function() { it("should return a code", async function() { let result = await fetchPlayerCode(new FetchPlayerCodeRequest("test")); assert(typeof result.playerCode === "string", "no code returned"); assert(result.playerCode.length === 10, "incorrect code length returned"); assert.equal(result.controllerUrl, "http://controller.example/"); }); it("should expire the code after the timeout", async function() { const start = Date.now(); const playerCode = await addPlayer("test", { timeout: 10 }); const end = Date.now(); const result = await postJSON( `${controllerUrl}/api/player_auth/player_code`, { player_code: playerCode } ); const { exp } = jwt.decode((await result.json()).verify_token); assert(exp >= Math.floor((start + 10e3) / 1000), "expiry before expected window"); assert(exp <= Math.floor((end + 10e3) / 1000), "expiry after expected window"); }); }); describe("SetVerifyCodeRequest handler", function() { it("should throw if player does not exist", async function() { await assert.rejects( setVerifyCode(new SetVerifyCodeRequest("invalid", "invalid")), new lib.NotFoundError("invalid player") ); }); it("should throw if player code has expired", async function() { await addPlayer("expired", { timeout: -1 }); await assert.rejects( setVerifyCode(new SetVerifyCodeRequest("expired", "expired")), new lib.NotFoundError("invalid player") ); }); }); describe("integration", function() { it("should verify a full login flow", async function() { // A one-second timeout leaves only 1ms before the JWT's rounded expiry here. now = Math.floor(now / 1000) * 1000 + 999; let { playerCode } = await fetchPlayerCode(new FetchPlayerCodeRequest("test")); const playerCodeResult = await postJSON( `${controllerUrl}/api/player_auth/player_code`, { player_code: playerCode } ); let { verify_code, verify_token } = await playerCodeResult.json(); await setVerifyCode(new SetVerifyCodeRequest("test", verify_code)); const user = mockController.users.getByName("test"); const token = mockController.users.signUserToken(user); const verifyResult = await postJSON( `${controllerUrl}/api/player_auth/verify`, { player_code: playerCode, verify_code, verify_token } ); assert.equal(verifyResult.status, 200); assert.deepEqual(await verifyResult.json(), { verified: true, token: token }); }); it("should reject a verification token at its expiry", async function() { const playerCode = await addPlayer("test"); const playerCodeResult = await postJSON( `${controllerUrl}/api/player_auth/player_code`, { player_code: playerCode } ); const { verify_code, verify_token } = await playerCodeResult.json(); await setVerifyCode(new SetVerifyCodeRequest("test", verify_code)); now = jwt.decode(verify_token).exp * 1000; const verifyResult = await postJSON( `${controllerUrl}/api/player_auth/verify`, { player_code: playerCode, verify_code, verify_token } ); assert.equal(verifyResult.status, 200); assert.deepEqual(await verifyResult.json(), { error: true, message: "jwt expired" }); }); }); }); }); describe("instance.js", function() { describe("entrypoint", function() { let instance; let host; before(async function() { try { lib.registerPluginMessages([info]); } catch (err) { // Already registered by the full test suite } ({ instance, host } = await mock.loadInstancePlugin(instancePlugin.default, info)); }); // Emits an ipc-player_auth event and returns the first RCON command it sent async function emitIpc(event) { instance.server.reset(); instance.server.emit("ipc-player_auth", event); while (!instance.server.rconCommands.length) { await new Promise(resolve => setImmediate(resolve)); } return instance.server.rconCommands[0]; } describe("ipc-player_auth", function() { describe("open_dialog", function() { it("should call /web-login error if not connected to controller", async function() { host.connector.connected = false; let command = await emitIpc({ type: "open_dialog", player: "test" }); host.connector.connected = true; assert.equal(command, "/web-login error test login is temporarily unavailable"); }); it("should call /web-login error after error from the controller", async function() { instance.connector.once("send", message => { instance.connector.emit("message", new lib.MessageResponseError(1, message.dst, message.src, new lib.ResponseError("controller error") ) ); }); let command = await emitIpc({ type: "open_dialog", player: "test" }); assert.equal(command, "/web-login error test controller error"); }); it("should call /web-login open after a valid response from the controller", async function() { instance.connector.once("send", message => { instance.connector.emit("message", new lib.MessageResponse(1, message.dst, message.src, new FetchPlayerCodeRequest.Response("code", "controller-url") ) ); }); let command = await emitIpc({ type: "open_dialog", player: "test" }); assert.equal(command, "/web-login open test controller-url code"); }); }); describe("set_verify_code", function() { it("should call /web-login code_set after a valid response from the controller", async function() { instance.connector.once("send", message => { instance.connector.emit("message", new lib.MessageResponse(1, message.dst, message.src) ); }); let command = await emitIpc({ type: "set_verify_code", player: "test", verify_code: "verify" }); assert.equal(command, "/web-login code_set test"); }); it("should call /web-login error after error from the controller", async function() { instance.connector.once("send", message => { instance.connector.emit("message", new lib.MessageResponseError(1, message.dst, message.src, new lib.ResponseError("controller error") ) ); }); let command = await emitIpc({ type: "set_verify_code", player: "test", verify_code: "verify" }); assert.equal(command, "/web-login error test controller error"); }); }); }); }); }); });