diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91a63af6..6a2dedfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ If you want to disable either for some reason, pass `--no-verify` to `git push` At that point you can make changes to the xmpp.js code and run tests with -``` +```sh make test ``` @@ -31,7 +31,7 @@ See [Jest CLI](https://jestjs.io/docs/cli). When submitting a pull request, additional tests will be run on GitHub actions. In most cases it shouldn't be necessary but if they fail, you can run them locally after installing prosody >= 0.12 with -``` +```sh make ci ``` diff --git a/packages/client-core/src/bind2/bind2.js b/packages/client-core/src/bind2/bind2.js index fa1ae5c1..d2704bac 100644 --- a/packages/client-core/src/bind2/bind2.js +++ b/packages/client-core/src/bind2/bind2.js @@ -1,14 +1,14 @@ import xml from "@xmpp/xml"; -const NS_BIND = "urn:xmpp:bind:0"; +const NS = "urn:xmpp:bind:0"; export default function bind2({ sasl2, entity }, tag) { const features = new Map(); sasl2.use( - NS_BIND, + NS, async (element) => { - if (!element.is("bind", NS_BIND)) return; + if (!element.is("bind", NS)) return; tag = typeof tag === "function" ? await tag() : tag; diff --git a/packages/client/index.js b/packages/client/index.js index 341d09e6..81e18b62 100644 --- a/packages/client/index.js +++ b/packages/client/index.js @@ -59,21 +59,13 @@ function client(options = {}) { }).map(([k, v]) => ({ [k]: v(saslFactory) })); // eslint-disable-next-line n/no-unsupported-features/node-builtins - const id = globalThis.crypto?.randomUUID?.(); - - let user_agent = - userAgent instanceof xml.Element - ? userAgent - : xml("user-agent", { id: userAgent?.id || id }, [ - userAgent?.software && xml("software", {}, userAgent.software), - userAgent?.device && xml("device", {}, userAgent.device), - ]); + userAgent ??= xml("user-agent", { id: globalThis.crypto.randomUUID() }); // Stream features - order matters and define priority const starttls = setupIfAvailable(_starttls, { streamFeatures }); const sasl2 = _sasl2( { streamFeatures, saslFactory }, - createOnAuthenticate(credentials ?? { username, password }, user_agent), + createOnAuthenticate(credentials ?? { username, password }, userAgent), ); const fast = setupIfAvailable(_fast, { diff --git a/packages/connection/index.js b/packages/connection/index.js index a533bb3e..77f53d2e 100644 --- a/packages/connection/index.js +++ b/packages/connection/index.js @@ -282,7 +282,6 @@ class Connection extends EventEmitter { */ async stop() { const el = await this._end(); - this.jid = null; this._status("offline", el); return el; } diff --git a/packages/connection/test/stop.js b/packages/connection/test/stop.js index 59eb456d..5057068b 100644 --- a/packages/connection/test/stop.js +++ b/packages/connection/test/stop.js @@ -1,5 +1,4 @@ import Connection from "../index.js"; -import { JID } from "@xmpp/test"; test("resolves if socket property is undefined", async () => { const conn = new Connection(); @@ -39,12 +38,3 @@ test("does not throw if connection is not established", async () => { await conn.stop(); expect().pass(); }); - -test("resets jid", async () => { - const conn = new Connection(); - conn.jid = new JID("foo@bar"); - - expect(conn.jid).not.toEqual(null); - await conn.stop(); - expect(conn.jid).toEqual(null); -}); diff --git a/packages/sasl2/README.md b/packages/sasl2/README.md index 00076e43..f72e2fc2 100644 --- a/packages/sasl2/README.md +++ b/packages/sasl2/README.md @@ -32,7 +32,7 @@ Uses cases: - Fetch credentials from a secure database ```js -import { xmpp } from "@xmpp/client"; +import { xmpp, xml } from "@xmpp/client"; const client = xmpp({ credentials: authenticate, @@ -60,7 +60,10 @@ async function getUserAgent() { localStorage.set("user-agent-id", id); } // https://xmpp.org/extensions/xep-0388.html#initiation - return { id, software: "xmpp.js", device: "Sonny's Laptop" }; // You can also pass an xml.Element + return xml("user-agent", { id }, [ + xml("software", {}, "xmpp.js"), + xml("device", {}, "Sonny's laptop"), + ]); } ``` diff --git a/server/ctl.js b/server/ctl.js index 44cb5a9f..447c1c38 100755 --- a/server/ctl.js +++ b/server/ctl.js @@ -2,7 +2,8 @@ import server from "./index.js"; -const method = process.argv[2]; +// eslint-disable-next-line unicorn/no-unreadable-array-destructuring +const [, , method, ...args] = process.argv; const commands = { start() { @@ -22,10 +23,18 @@ const commands = { console.log("stopped"); } }, + async enable(...args) { + await server.enableModules(...args); + await this.restart(); + }, + async disable(...args) { + await server.disableModules(...args); + await this.restart(); + }, }; if (commands[method]) { - await commands[method](); + await commands[method](...args); } else { - console.error("Valid commands are start/stop/restart/status."); + console.error("Valid commands are start/stop/restart/status/enable/disable."); } diff --git a/server/index.js b/server/index.js index 2f9c4c22..f3066e01 100644 --- a/server/index.js +++ b/server/index.js @@ -1,6 +1,6 @@ import { promisify } from "util"; import path from "path"; -import fs, { writeFileSync } from "fs"; +import fs from "fs/promises"; import child_process from "child_process"; import net from "net"; // eslint-disable-next-line n/no-extraneous-import @@ -10,18 +10,17 @@ import selfsigned from "selfsigned"; const __dirname = "./server"; // const __dirname = import.meta.dirname; -const readFile = promisify(fs.readFile); const exec = promisify(child_process.exec); -const removeFile = promisify(fs.unlink); const DATA_PATH = path.join(__dirname); const PID_PATH = path.join(DATA_PATH, "prosody.pid"); const PROSODY_PORT = 5347; +const CFG_PATH = path.join(__dirname, "prosody.cfg.lua"); function clean() { return Promise.all( ["prosody.err", "prosody.log", "prosody.pid"].map((file) => - removeFile(path.join(__dirname, file)), + fs.unlink(path.join(__dirname, file)), ), ).catch(() => {}); } @@ -47,12 +46,14 @@ async function waitPortOpen() { return waitPortOpen(); } -function makeCertificate() { +async function makeCertificate() { const attrs = [{ name: "commonName", value: "localhost" }]; const pems = selfsigned.generate(attrs, { days: 365, keySize: 2048 }); - writeFileSync(path.join(__dirname, "certs/localhost.crt"), pems.cert); - writeFileSync(path.join(__dirname, "certs/localhost.key"), pems.private); + await Promise.all([ + fs.writeFile(path.join(__dirname, "certs/localhost.crt"), pems.cert), + fs.writeFile(path.join(__dirname, "certs/localhost.key"), pems.private), + ]); } async function waitPortClose() { @@ -75,7 +76,7 @@ async function kill(signal = "SIGTERM") { async function getPid() { try { - return await readFile(PID_PATH, "utf8"); + return await fs.readFile(PID_PATH, "utf8"); } catch (err) { if (err.code !== "ENOENT") throw err; return ""; @@ -119,6 +120,34 @@ async function restart(signal) { return _start(); } +async function enableModules(mods) { + if (!Array.isArray(mods)) { + mods = [mods]; + } + + let prosody_cfg = await fs.readFile(CFG_PATH, "utf8"); + for (const mod of mods) { + prosody_cfg = prosody_cfg.replace(`\n -- "${mod}";`, `\n "${mod}";`); + } + await fs.writeFile(CFG_PATH, prosody_cfg); +} + +async function disableModules(mods) { + if (!Array.isArray(mods)) { + mods = [mods]; + } + + let prosody_cfg = await fs.readFile(CFG_PATH, "utf8"); + for (const mod of mods) { + prosody_cfg = prosody_cfg.replace(`\n "${mod}";`, `\n -- "${mod}";`); + } + await fs.writeFile(CFG_PATH, prosody_cfg); +} + +async function reset() { + await exec("git checkout server/prosody.cfg.lua"); +} + export default { isPortOpen, waitPortClose, @@ -128,4 +157,7 @@ export default { stop, restart, kill, + enableModules, + disableModules, + reset, }; diff --git a/test/sasl.js b/test/sasl.js new file mode 100644 index 00000000..47f5e095 --- /dev/null +++ b/test/sasl.js @@ -0,0 +1,125 @@ +import { client, jid } from "../packages/client/index.js"; +import debug from "../packages/debug/index.js"; +import server from "../server/index.js"; + +const NS_SASL = "urn:ietf:params:xml:ns:xmpp-sasl"; +const NS_BIND = "urn:ietf:params:xml:ns:xmpp-bind"; +const NS_SASL2 = "urn:xmpp:sasl:2"; +const NS_BIND2 = "urn:xmpp:bind:0"; +const NS_FAST = "urn:xmpp:fast:0"; + +const username = "client"; +const password = "foobar"; +const credentials = { username, password }; +const domain = "localhost"; +const JID = jid(username, domain).toString(); + +let xmpp; + +afterEach(async () => { + await xmpp?.stop(); + await server.reset(); +}); + +test("client online with sasl and resource binding", async () => { + expect.assertions(6); + + await server.disableModules([ + "sasl2", + "sasl2_bind2", + "sasl2_sm", + "sasl2_fast", + ]); + await server.enableModules(["saslauth"]); + await server.restart(); + + xmpp = client({ credentials, service: domain }); + debug(xmpp); + + xmpp.on("nonza", (element) => { + if (!element.is("features")) return; + + expect(element.getChild("authentication", NS_SASL2)).toBe(undefined); + if (element.getChild("mechanisms", NS_SASL)) expect.pass(); + }); + + xmpp.on("send", (el) => { + if (el.is("auth", NS_SASL)) expect().pass(); + if (el.is("iq") && el.getChild("bind", NS_BIND)) expect().pass(); + }); + + const address = await xmpp.start(); + expect(address instanceof jid.JID).toBe(true); + expect(address.bare().toString()).toBe(JID); +}); + +test("client online with sasl2 and bind2", async () => { + expect.assertions(6); + + await server.disableModules(["saslauth"]); + await server.enableModules(["sasl2", "sasl2_bind2"]); + await server.restart(); + + xmpp = client({ credentials, service: domain }); + debug(xmpp); + + xmpp.on("nonza", (element) => { + if (!element.is("features")) return; + + expect(element.getChild("mechanisms", NS_SASL)).toBe(undefined); + if (element.getChild("authentication", NS_SASL2)) expect.pass(); + }); + + xmpp.on("send", (el) => { + if (!el.is("authenticate", NS_SASL2)) return; + expect().pass(); + if (el.getChild("bind", NS_BIND2)) expect().pass(); + }); + + const address = await xmpp.start(); + expect(address instanceof jid.JID).toBe(true); + expect(address.bare().toString()).toBe(JID); +}); + +test("client online with sasl2 and fast", async () => { + expect.assertions(3); + + await server.disableModules(["saslauth"]); + await server.enableModules([ + "sasl2", + "sasl2_bind2", + "sasl2_sm", + "sasl2_fast", + ]); + await server.restart(); + + xmpp = client({ + ...credentials, + service: "ws://localhost:5280/xmpp-websocket", + }); + + // Get token + await xmpp.start(); + await xmpp.stop(); + + debug(xmpp); + + xmpp.on("nonza", (element) => { + if (!element.is("features")) return; + + const authentication = element.getChild("authentication", NS_SASL2); + if (!authentication) return; + const inline = authentication.getChild("inline"); + expect(inline.getChild("fast", NS_FAST)).not.toBe(undefined); + }); + + xmpp.on("send", (el) => { + const authenticate = el.is("authenticate", NS_SASL2); + if (!authenticate) return; + + expect(el.attrs.mechanism).toBe("HT-SHA-256-NONE"); + expect(el.getChild("fast", NS_FAST)).not.toBe(undefined); + }); + + await xmpp.start(); +});