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 = {