diff --git a/Makefile b/Makefile index 30abfe93..11e52185 100644 --- a/Makefile +++ b/Makefile @@ -33,8 +33,9 @@ unit: e2e: $(warning e2e tests require prosody-trunk and luarocks) cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2 > /dev/null + cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_bind2 > /dev/null + # https://github.com/xmppjs/xmpp.js/pull/1006 -# cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_bind2 > /dev/null # cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_fast > /dev/null # cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_sm > /dev/null npm run e2e @@ -46,6 +47,8 @@ clean: rm -f server/prosody.err rm -f server/prosody.log rm -f server/prosody.pid + rm -rf server/modules + rm -rf server/.cache npx lerna clean --yes rm -rf node_modules/ rm -f packages/*/dist/*.js diff --git a/package-lock.json b/package-lock.json index 3e827a7a..d9e9792c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14891,6 +14891,7 @@ "@xmpp/connection": "^0.14.0", "@xmpp/debug": "^0.14.0", "@xmpp/events": "^0.14.0", + "@xmpp/id": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/xml": "^0.14.0", "ltx": "^3.1.1" diff --git a/packages/client-core/src/bind2/README.md b/packages/client-core/src/bind2/README.md new file mode 100644 index 00000000..ac634dd3 --- /dev/null +++ b/packages/client-core/src/bind2/README.md @@ -0,0 +1,36 @@ +# bind2 + +bind2 for `@xmpp/client`. + +Included and enabled in `@xmpp/client`. + +## Usage + +Resource is optional and will be chosen by the server if omitted. + +### string + +```js +import { xmpp } from "@xmpp/client"; + +const client = xmpp({ resource: "laptop" }); +``` + +### function + +Instead, you can provide a function that will be called every time resource binding occurs (every (re)connect). + +```js +import { xmpp } from "@xmpp/client"; + +const client = xmpp({ resource: onBind }); + +async function onBind(bind) { + const resource = await fetchResource(); + return resource; +} +``` + +## References + +[XEP-0386: Bind 2](https://xmpp.org/extensions/xep-0386.html) diff --git a/packages/client-core/src/bind2/bind2.js b/packages/client-core/src/bind2/bind2.js new file mode 100644 index 00000000..5862e115 --- /dev/null +++ b/packages/client-core/src/bind2/bind2.js @@ -0,0 +1,17 @@ +import xml from "@xmpp/xml"; + +const NS_BIND = "urn:xmpp:bind:0"; + +export default function bind2({ sasl2 }, tag) { + sasl2.use(NS_BIND, async (element) => { + if (!element.is("bind", NS_BIND)) return; + + tag = typeof tag === "function" ? await tag() : tag; + + return xml( + "bind", + { xmlns: "urn:xmpp:bind:0" }, + tag && xml("tag", null, tag), + ); + }); +} diff --git a/packages/client-core/src/bind2/bind2.test.js b/packages/client-core/src/bind2/bind2.test.js new file mode 100644 index 00000000..b0aac358 --- /dev/null +++ b/packages/client-core/src/bind2/bind2.test.js @@ -0,0 +1,117 @@ +import { mockClient, id, promiseError } from "@xmpp/test"; + +function mockFeatures(entity) { + entity.mockInput( + + + PLAIN + + + + + , + ); +} + +function catchAuthenticate(entity) { + return entity.catchOutgoing((stanza) => { + if (stanza.is("authenticate", "urn:xmpp:sasl:2")) return true; + }); +} + +test("without tag", async () => { + const { entity } = mockClient(); + mockFeatures(entity); + const stanza = await catchAuthenticate(entity); + + await expect(stanza.getChild("bind", "urn:xmpp:bind:0").toString()).toEqual( + ().toString(), + ); +}); + +test("with string tag", async () => { + const resource = id(); + const { entity } = mockClient({ resource }); + mockFeatures(entity); + const stanza = await catchAuthenticate(entity); + + expect(stanza.getChild("bind", "urn:xmpp:bind:0").toString()).toEqual( + ( + + {resource} + + ).toString(), + ); +}); + +test("with function resource returning string", async () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + function resource() { + return "1k2k3"; + } + + const { entity } = mockClient({ resource }); + mockFeatures(entity); + const stanza = await catchAuthenticate(entity); + + expect(stanza.getChild("bind", "urn:xmpp:bind:0").toString()).toEqual( + ( + + {resource()} + + ).toString(), + ); +}); + +test("with function resource throwing", async () => { + const error = new Error("foo"); + + + function resource() { + throw error; + } + + const { entity } = mockClient({ resource }); + + const willError = promiseError(entity); + + mockFeatures(entity); + + expect(await willError).toBe(error); +}); + +test("with function resource returning resolved promise", async () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + async function resource() { + return "1k2k3"; + } + + const { entity } = mockClient({ resource }); + mockFeatures(entity); + const stanza = await catchAuthenticate(entity); + + expect(stanza.getChild("bind", "urn:xmpp:bind:0").toString()).toEqual( + ( + + {await resource()} + + ).toString(), + ); +}); + +test("with function resource returning rejected promise", async () => { + const error = new Error("foo"); + + + async function resource() { + throw error; + } + + const { entity } = mockClient({ resource }); + + const willError = promiseError(entity); + + mockFeatures(entity); + + expect(await willError).toBe(error); +}); diff --git a/packages/client/index.js b/packages/client/index.js index 6b52adab..86bbc2fb 100644 --- a/packages/client/index.js +++ b/packages/client/index.js @@ -17,6 +17,7 @@ import _sasl from "@xmpp/sasl"; import _resourceBinding from "@xmpp/resource-binding"; import _sessionEstablishment from "@xmpp/session-establishment"; import _streamManagement from "@xmpp/stream-management"; +import _bind2 from "@xmpp/client-core/src/bind2/bind2.js"; import SASLFactory from "saslmechanisms"; import scramsha1 from "@xmpp/sasl-scram-sha-1"; @@ -59,7 +60,6 @@ function client(options = {}) { { streamFeatures, saslFactory }, createOnAuthenticate(credentials ?? { username, password }, userAgent), ); - service; const sasl = _sasl( { streamFeatures, saslFactory }, createOnAuthenticate(credentials ?? { username, password }, userAgent), @@ -78,6 +78,9 @@ function client(options = {}) { streamFeatures, }); + // SASL2 inline features + const bind2 = _bind2({ sasl2 }, resource); + return Object.assign(entity, { entity, reconnect, @@ -97,6 +100,7 @@ function client(options = {}) { sessionEstablishment, streamManagement, mechanisms, + bind2, }); } diff --git a/packages/resource-binding/README.md b/packages/resource-binding/README.md index 6c4ee530..1fb96cc2 100644 --- a/packages/resource-binding/README.md +++ b/packages/resource-binding/README.md @@ -20,29 +20,14 @@ const client = xmpp({ resource: "laptop" }); Instead, you can provide a function that will be called every time resource binding occurs (every (re)connect). -Uses cases: - -- Have the user choose a resource every time -- Do not ask for resource before connection is made -- Debug resource binding -- Perform an asynchronous operation to get the resource - ```js import { xmpp } from "@xmpp/client"; -const client = xmpp({ resource: bindResource }); - -async function bindResource(bind) { - console.debug("bind"); - const value = await prompt("enter resource"); - console.debug("binding"); - try { - const { resource } = await bind(value); - console.debug("bound", resource); - } catch (err) { - console.error(err); - throw err; - } +const client = xmpp({ resource: onBind }); + +async function onBind(bind) { + const resource = await fetchResource(); + return resource; } ``` diff --git a/packages/resource-binding/index.js b/packages/resource-binding/index.js index 5a0372e0..5324db22 100644 --- a/packages/resource-binding/index.js +++ b/packages/resource-binding/index.js @@ -20,10 +20,8 @@ async function bind(entity, iqCaller, resource) { function route({ iqCaller }, resource) { return async ({ entity }, next) => { - await (typeof resource === "function" - ? resource((resource) => bind(entity, iqCaller, resource)) - : bind(entity, iqCaller, resource)); - + resource = typeof resource === "function" ? await resource() : resource; + await bind(entity, iqCaller, resource); next(); }; } diff --git a/packages/resource-binding/test.js b/packages/resource-binding/test.js index 99fd7f99..9d0291ea 100644 --- a/packages/resource-binding/test.js +++ b/packages/resource-binding/test.js @@ -61,10 +61,8 @@ test("with function resource", async () => { const jid = "foo@bar/" + resource; const { entity } = mockClient({ - resource: async (bind) => { - await delay(); - const result = await bind(resource); - expect(result.toString()).toBe(jid); + resource: async () => { + return resource; }, }); diff --git a/packages/sasl/index.js b/packages/sasl/index.js index 78918517..f4a4b596 100644 --- a/packages/sasl/index.js +++ b/packages/sasl/index.js @@ -6,11 +6,8 @@ import xml from "@xmpp/xml"; const NS = "urn:ietf:params:xml:ns:xmpp-sasl"; -function getMechanismNames(stanza) { - return stanza - .getChild("mechanisms", NS) - .getChildElements() - .map((el) => el.text()); +function getMechanismNames(element) { + return element.getChildElements().map((el) => el.text()); } async function authenticate({ saslFactory, entity, mechanism, credentials }) { @@ -74,8 +71,8 @@ async function authenticate({ saslFactory, entity, mechanism, credentials }) { } export default function sasl({ streamFeatures, saslFactory }, onAuthenticate) { - streamFeatures.use("mechanisms", NS, async ({ stanza, entity }) => { - const offered = getMechanismNames(stanza); + streamFeatures.use("mechanisms", NS, async ({ entity }, _next, element) => { + const offered = getMechanismNames(element); const supported = saslFactory._mechs.map(({ name }) => name); const intersection = supported.filter((mech) => offered.includes(mech)); diff --git a/packages/sasl2/index.js b/packages/sasl2/index.js index 10a01e62..b8ad5e46 100644 --- a/packages/sasl2/index.js +++ b/packages/sasl2/index.js @@ -8,10 +8,7 @@ import xml from "@xmpp/xml"; const NS = "urn:xmpp:sasl:2"; function getMechanismNames(stanza) { - return stanza - .getChild("authentication", NS) - .getChildren("mechanism", NS) - .map((m) => m.text()); + return stanza.getChildren("mechanism", NS).map((m) => m.text()); } async function authenticate({ @@ -20,6 +17,7 @@ async function authenticate({ mechanism, credentials, userAgent, + sessionFeatures, }) { const mech = saslFactory.create([mechanism]); if (!mech) { @@ -91,40 +89,75 @@ async function authenticate({ entity.removeListener("nonza", handler); }; - entity.send( - xml("authenticate", { xmlns: NS, mechanism: mech.name }, [ - mech.clientFirst && - xml("initial-response", {}, encode(mech.response(creds))), - userAgent, - ]), - ); - entity.on("nonza", handler); + + entity + .send( + xml("authenticate", { xmlns: NS, mechanism: mech.name }, [ + mech.clientFirst && + xml("initial-response", {}, encode(mech.response(creds))), + userAgent, + ...sessionFeatures, + ]), + ) + .catch(reject); }); } export default function sasl2({ streamFeatures, saslFactory }, onAuthenticate) { - streamFeatures.use("authentication", NS, async ({ stanza, entity }) => { - const offered = getMechanismNames(stanza); - const supported = saslFactory._mechs.map(({ name }) => name); - const intersection = supported.filter((mech) => offered.includes(mech)); - - if (intersection.length === 0) { - throw new SASLError("SASL: No compatible mechanism available."); - } - - async function done(credentials, mechanism, userAgent) { - await authenticate({ - saslFactory, - entity, - mechanism, - credentials, - userAgent, - }); - } - - await onAuthenticate(done, intersection); - - return true; // Not online yet, wait for next features - }); + // inline + const features = new Map(); + + streamFeatures.use( + "authentication", + NS, + async ({ entity }, _next, element) => { + const offered = getMechanismNames(element); + const supported = saslFactory._mechs.map(({ name }) => name); + const intersection = supported.filter((mech) => offered.includes(mech)); + + if (intersection.length === 0) { + throw new SASLError("SASL: No compatible mechanism available."); + } + + const sessionFeatures = await getSessionFeatures({ element, features }); + + async function done(credentials, mechanism, userAgent) { + await authenticate({ + saslFactory, + entity, + mechanism, + credentials, + userAgent, + sessionFeatures, + }); + } + + await onAuthenticate(done, intersection); + + return true; // Not online yet, wait for next features + }, + ); + + return { + use(ns, req, res) { + features.set(ns, req, res); + }, + }; +} + +function getSessionFeatures({ element, features }) { + const promises = []; + + const inline = element.getChild("inline"); + if (!inline) return promises; + + for (const element of inline.getChildElements()) { + const xmlns = element.getNS(); + const feature = features.get(xmlns); + if (!feature) continue; + promises.push(feature(element)); + } + + return Promise.all(promises); } diff --git a/packages/stream-features/test.js b/packages/stream-features/test.js deleted file mode 100644 index e0900d53..00000000 --- a/packages/stream-features/test.js +++ /dev/null @@ -1,24 +0,0 @@ -import streamfeatures from "./index.js"; -import { xml } from "@xmpp/test"; - -test.skip("selectFeature", () => { - const features = []; - features.push( - { - priority: 1000, - run: () => {}, - match: (el) => el.getChild("bind"), - }, - { - priority: 2000, - run: () => {}, - match: (el) => el.getChild("bind"), - }, - ); - - const feature = streamfeatures.selectFeature( - features, - xml("foo", {}, xml("bind")), - ); - expect(feature.priority).toBe(2000); -}); diff --git a/packages/test/index.js b/packages/test/index.js index 0c534d58..88ca81ee 100644 --- a/packages/test/index.js +++ b/packages/test/index.js @@ -3,8 +3,19 @@ import xml from "@xmpp/xml"; import jid from "@xmpp/jid"; import mockClient from "./mockClient.js"; import { delay, promise, timeout } from "@xmpp/events"; +import id from "@xmpp/id"; -export { context, xml, jid, jid as JID, mockClient, delay, promise, timeout }; +export { + context, + xml, + jid, + jid as JID, + mockClient, + delay, + promise, + timeout, + id, +}; export function mockInput(entity, el) { entity.emit("input", el.toString()); diff --git a/packages/test/package.json b/packages/test/package.json index 8d14e8c2..99583494 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -21,6 +21,7 @@ "@xmpp/connection": "^0.14.0", "@xmpp/debug": "^0.14.0", "@xmpp/events": "^0.14.0", + "@xmpp/id": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/xml": "^0.14.0", "ltx": "^3.1.1" diff --git a/server/prosody.cfg.lua b/server/prosody.cfg.lua index 499cda1f..56f05b3a 100644 --- a/server/prosody.cfg.lua +++ b/server/prosody.cfg.lua @@ -2,7 +2,7 @@ -- DO NOT COPY BLINDLY -- see https://prosody.im/doc/configure -local lfs = require "lfs"; +local lfs = Lua.require "lfs"; plugin_paths = { "modules" } plugin_server = "https://modules.prosody.im/rocks/" @@ -23,8 +23,8 @@ modules_enabled = { "version"; "smacks"; "sasl2"; + "sasl2_bind2"; -- https://github.com/xmppjs/xmpp.js/pull/1006 - -- "sasl2_bind2"; -- "sasl2_fast"; -- "sasl2_sm"; };