diff --git a/Makefile b/Makefile index 30abfe93..9e717c36 100644 --- a/Makefile +++ b/Makefile @@ -33,10 +33,10 @@ 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 + cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_sm > /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 clean: @@ -46,6 +46,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..89faed20 --- /dev/null +++ b/packages/client-core/src/bind2/bind2.js @@ -0,0 +1,57 @@ +import xml from "@xmpp/xml"; + +const NS_BIND = "urn:xmpp:bind:0"; + +export default function bind2({ sasl2, entity }, tag) { + const features = new Map(); + + sasl2.use( + NS_BIND, + async (element) => { + if (!element.is("bind", NS_BIND)) return; + + tag = typeof tag === "function" ? await tag() : tag; + + const sessionFeatures = await getSessionFeatures({ element, features }); + + return xml( + "bind", + { xmlns: "urn:xmpp:bind:0" }, + tag && xml("tag", null, tag), + ...sessionFeatures, + ); + }, + (element) => { + const aid = element.root().getChildText("authorization-identifier"); + if (aid) entity._jid(aid); + + for (const child of element.getChildElements()) { + const feature = features.get(child.getNS()); + if (!feature?.[1]) continue; + feature?.[1](child); + } + }, + ); + + 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.attrs.var; + const feature = features.get(xmlns); + if (!feature) continue; + promises.push(feature[0](element)); + } + + return Promise.all(promises); +} 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..54a35d90 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,11 @@ function client(options = {}) { { streamFeatures, saslFactory }, createOnAuthenticate(credentials ?? { username, password }, userAgent), ); - service; + + // SASL2 inline features + const bind2 = _bind2({ sasl2, entity }, resource); + + // Stream features - order matters and define priority const sasl = _sasl( { streamFeatures, saslFactory }, createOnAuthenticate(credentials ?? { username, password }, userAgent), @@ -68,6 +73,7 @@ function client(options = {}) { streamFeatures, entity, middleware, + bind2, }); const resourceBinding = _resourceBinding( { iqCaller, streamFeatures }, @@ -97,6 +103,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..ab191d4f 100644 --- a/packages/sasl2/index.js +++ b/packages/sasl2/index.js @@ -1,6 +1,5 @@ import { encode, decode } from "@xmpp/base64"; import SASLError from "@xmpp/sasl/lib/SASLError.js"; -import jid from "@xmpp/jid"; import xml from "@xmpp/xml"; // https://xmpp.org/extensions/xep-0388.html @@ -8,10 +7,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 +16,8 @@ async function authenticate({ mechanism, credentials, userAgent, + streamFeatures, + features, }) { const mech = saslFactory.create([mechanism]); if (!mech) { @@ -63,7 +61,6 @@ async function authenticate({ } if (element.name === "continue") { - // No tasks supported yet reject(new Error("continue is not supported yet")); return; } @@ -73,17 +70,13 @@ async function authenticate({ if (additionalData && mech.final) { mech.final(decode(additionalData)); } - // This jid will be bare unless we do inline bind2 then it will be the bound full jid - const aid = element.getChild("authorization-identifier")?.text(); - if (aid) { - if (!entity.jid?.resource) { - // No jid or bare jid, so update it - entity._jid(aid); - } else if (jid(aid).resource) { - // We have a full jid so use it - entity._jid(aid); - } + + for (const child of element.getChildElements()) { + const feature = features.get(child.getNS()); + if (!feature?.[1]) continue; + feature?.[1](child); } + resolve(element); return; } @@ -91,40 +84,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, + ...streamFeatures, + ]), + ) + .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 - }); + 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 streamFeatures = await getStreamFeatures({ element, features }); + + async function done(credentials, mechanism, userAgent) { + await authenticate({ + saslFactory, + entity, + mechanism, + credentials, + userAgent, + streamFeatures, + features, + }); + } + + await onAuthenticate(done, intersection); + + return true; // Not online yet, wait for next features + }, + ); + + return { + use(ns, req, res) { + features.set(ns, [req, res]); + }, + }; +} + +function getStreamFeatures({ 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[0](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/stream-management/index.js b/packages/stream-management/index.js index 4ec2bff7..d3d2667d 100644 --- a/packages/stream-management/index.js +++ b/packages/stream-management/index.js @@ -42,9 +42,8 @@ export default function streamManagement({ streamFeatures, entity, middleware, + bind2, }) { - let address = null; - const sm = { allowResume: true, preferredMaximum: null, @@ -55,8 +54,7 @@ export default function streamManagement({ max: null, }; - entity.on("online", (jid) => { - address = jid; + entity.on("online", () => { sm.outbound = 0; sm.inbound = 0; }); @@ -83,9 +81,23 @@ export default function streamManagement({ return next(); }); + if (bind2) { + setupBind2({ bind2, sm, entity }); + } else { + setupStreamFeature({ streamFeatures, sm, entity }); + } + + return sm; +} + +function setupStreamFeature({ streamFeatures, sm, entity }) { + let address = null; + entity.on("online", (jid) => { + address = jid; + }); + // https://xmpp.org/extensions/xep-0198.html#enable // For client-to-server connections, the client MUST NOT attempt to enable stream management until after it has completed Resource Binding unless it is resuming a previous session - streamFeatures.use("sm", NS, async (context, next) => { // Resuming if (sm.id) { @@ -124,6 +136,28 @@ export default function streamManagement({ sm.inbound = 0; }); +} - return sm; +function setupBind2({ bind2, sm, entity }) { + let address = null; + entity.on("online", (jid) => { + address = jid; + }); + bind2.use( + "urn:xmpp:sm:3", + (element) => { + if (!element.is("feature")) return; + // eslint-disable-next-line unicorn/prefer-ternary + if (sm.id) { + return xml("resume", { xmlns: NS }); + } else { + return xml("enable", { xmlns: NS }); + } + }, + (element) => { + if (!element.is("enabled")) return; + console.log("ENABLED"); + sm.enabled = true; + }, + ); } 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..c917b4a2 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,10 +23,10 @@ modules_enabled = { "version"; "smacks"; "sasl2"; + "sasl2_bind2"; + "sasl2_sm"; -- https://github.com/xmppjs/xmpp.js/pull/1006 - -- "sasl2_bind2"; -- "sasl2_fast"; - -- "sasl2_sm"; }; modules_disabled = {