diff --git a/Makefile b/Makefile
index 11e52185..9e717c36 100644
--- a/Makefile
+++ b/Makefile
@@ -34,10 +34,9 @@ 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_fast > /dev/null
-# cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_sm > /dev/null
npm run e2e
clean:
diff --git a/packages/client-core/src/bind2/bind2.js b/packages/client-core/src/bind2/bind2.js
index 5862e115..4fd186bd 100644
--- a/packages/client-core/src/bind2/bind2.js
+++ b/packages/client-core/src/bind2/bind2.js
@@ -3,15 +3,51 @@ 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;
+ const features = new Map();
- tag = typeof tag === "function" ? await tag() : tag;
+ sasl2.use(
+ NS_BIND,
+ async (element) => {
+ if (!element.is("bind", NS_BIND)) return;
- return xml(
- "bind",
- { xmlns: "urn:xmpp:bind:0" },
- tag && xml("tag", null, tag),
- );
- });
+ 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) => {
+ for (const child of element.getChildElements()) {
+ const feature = features.get(child.getNS());
+ 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/README.md b/packages/client/README.md
index aeab2f58..18804533 100644
--- a/packages/client/README.md
+++ b/packages/client/README.md
@@ -55,7 +55,7 @@ xmpp.on("offline", () => {
console.log("offline");
});
-xmpp.on("stanza", async (stanza) => {
+xmpp.once("stanza", async (stanza) => {
if (stanza.is("message")) {
await xmpp.send(xml("presence", { type: "unavailable" }));
await xmpp.stop();
diff --git a/packages/client/example.js b/packages/client/example.js
index 6bab1abc..9bd5f21d 100644
--- a/packages/client/example.js
+++ b/packages/client/example.js
@@ -24,7 +24,7 @@ xmpp.on("offline", () => {
console.log("offline");
});
-xmpp.on("stanza", async (stanza) => {
+xmpp.once("stanza", async (stanza) => {
if (stanza.is("message")) {
await xmpp.send(xml("presence", { type: "unavailable" }));
await xmpp.stop();
diff --git a/packages/client/index.js b/packages/client/index.js
index 86bbc2fb..14bcb46c 100644
--- a/packages/client/index.js
+++ b/packages/client/index.js
@@ -60,6 +60,11 @@ function client(options = {}) {
{ streamFeatures, saslFactory },
createOnAuthenticate(credentials ?? { username, password }, userAgent),
);
+
+ // SASL2 inline features
+ const bind2 = _bind2({ sasl2 }, resource);
+
+ // Stream features - order matters and define priority
const sasl = _sasl(
{ streamFeatures, saslFactory },
createOnAuthenticate(credentials ?? { username, password }, userAgent),
@@ -68,6 +73,8 @@ function client(options = {}) {
streamFeatures,
entity,
middleware,
+ bind2,
+ sasl2,
});
const resourceBinding = _resourceBinding(
{ iqCaller, streamFeatures },
@@ -78,9 +85,6 @@ function client(options = {}) {
streamFeatures,
});
- // SASL2 inline features
- const bind2 = _bind2({ sasl2 }, resource);
-
return Object.assign(entity, {
entity,
reconnect,
diff --git a/packages/component/README.md b/packages/component/README.md
index 0a9c41e4..4a1fbb93 100644
--- a/packages/component/README.md
+++ b/packages/component/README.md
@@ -34,7 +34,7 @@ xmpp.on("offline", () => {
console.log("offline");
});
-xmpp.on("stanza", async (stanza) => {
+xmpp.once("stanza", async (stanza) => {
if (stanza.is("message")) {
await xmpp.stop();
}
diff --git a/packages/component/example.js b/packages/component/example.js
index ce5847b5..2c8bec4a 100644
--- a/packages/component/example.js
+++ b/packages/component/example.js
@@ -18,7 +18,7 @@ xmpp.on("offline", () => {
console.log("offline");
});
-xmpp.on("stanza", async (stanza) => {
+xmpp.once("stanza", async (stanza) => {
if (stanza.is("message")) {
await xmpp.stop();
}
diff --git a/packages/sasl2/index.js b/packages/sasl2/index.js
index b8ad5e46..29c237be 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
@@ -17,7 +16,8 @@ async function authenticate({
mechanism,
credentials,
userAgent,
- sessionFeatures,
+ streamFeatures,
+ features,
}) {
const mech = saslFactory.create([mechanism]);
if (!mech) {
@@ -38,7 +38,7 @@ async function authenticate({
return new Promise((resolve, reject) => {
const handler = (element) => {
- if (element.attrs.xmlns !== NS) {
+ if (element.getNS() !== NS) {
return;
}
@@ -61,7 +61,6 @@ async function authenticate({
}
if (element.name === "continue") {
- // No tasks supported yet
reject(new Error("continue is not supported yet"));
return;
}
@@ -71,19 +70,18 @@ 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);
- }
+
+ // https://xmpp.org/extensions/xep-0388.html#success
+ // this is a bare JID, unless resource binding has occurred, in which case it is a full JID.
+ const aid = element.getChildText("authorization-identifier");
+ if (aid) entity._jid(aid);
+
+ for (const child of element.getChildElements()) {
+ const feature = features.get(child.getNS());
+ feature?.[1]?.(child);
}
+
resolve(element);
- return;
}
entity.removeListener("nonza", handler);
@@ -97,7 +95,7 @@ async function authenticate({
mech.clientFirst &&
xml("initial-response", {}, encode(mech.response(creds))),
userAgent,
- ...sessionFeatures,
+ ...streamFeatures,
]),
)
.catch(reject);
@@ -105,7 +103,6 @@ async function authenticate({
}
export default function sasl2({ streamFeatures, saslFactory }, onAuthenticate) {
- // inline
const features = new Map();
streamFeatures.use(
@@ -120,7 +117,7 @@ export default function sasl2({ streamFeatures, saslFactory }, onAuthenticate) {
throw new SASLError("SASL: No compatible mechanism available.");
}
- const sessionFeatures = await getSessionFeatures({ element, features });
+ const streamFeatures = await getStreamFeatures({ element, features });
async function done(credentials, mechanism, userAgent) {
await authenticate({
@@ -129,24 +126,25 @@ export default function sasl2({ streamFeatures, saslFactory }, onAuthenticate) {
mechanism,
credentials,
userAgent,
- sessionFeatures,
+ streamFeatures,
+ features,
});
}
await onAuthenticate(done, intersection);
-
- return true; // Not online yet, wait for next features
+ // Not online yet, wait for next features
+ return true;
},
);
return {
use(ns, req, res) {
- features.set(ns, req, res);
+ features.set(ns, [req, res]);
},
};
}
-function getSessionFeatures({ element, features }) {
+function getStreamFeatures({ element, features }) {
const promises = [];
const inline = element.getChild("inline");
@@ -156,7 +154,7 @@ function getSessionFeatures({ element, features }) {
const xmlns = element.getNS();
const feature = features.get(xmlns);
if (!feature) continue;
- promises.push(feature(element));
+ promises.push(feature[0](element));
}
return Promise.all(promises);
diff --git a/packages/sasl2/test.js b/packages/sasl2/test.js
index 0be1f91a..0a7e696c 100644
--- a/packages/sasl2/test.js
+++ b/packages/sasl2/test.js
@@ -129,7 +129,7 @@ test("prefers SCRAM-SHA-1", async () => {
expect(result.attrs.mechanism).toEqual("SCRAM-SHA-1");
});
-test.skip("use ANONYMOUS if username and password are not provided", async () => {
+test("use ANONYMOUS if username and password are not provided", async () => {
const { entity } = mockClient();
entity.mockInput(
diff --git a/packages/stream-features/route.js b/packages/stream-features/route.js
index 03e6ceb8..0aed4f5c 100644
--- a/packages/stream-features/route.js
+++ b/packages/stream-features/route.js
@@ -3,6 +3,9 @@ export default function route() {
if (!stanza.is("features", "http://etherx.jabber.org/streams"))
return next();
+ // FIXME: instead of this prevent mechanism
+ // emit online once all stream features have negotiated
+ // and if entity.jid is set
const prevent = await next();
if (!prevent && entity.jid) entity._status("online", entity.jid);
};
diff --git a/packages/stream-management/README.md b/packages/stream-management/README.md
index 7377c3dd..296b8ad4 100644
--- a/packages/stream-management/README.md
+++ b/packages/stream-management/README.md
@@ -11,3 +11,7 @@ However `entity.status` is set to `online`.
If the session fails to resume, entity will fallback to regular session establishment in which case `online` event will be emitted.
Automatically responds to acks but does not support requesting acks yet.
+
+## References
+
+[XEP-0198: Stream Management](https://xmpp.org/extensions/xep-0198.html#inline-enable)
diff --git a/packages/stream-management/bind2.test.js b/packages/stream-management/bind2.test.js
new file mode 100644
index 00000000..4bdfdbbf
--- /dev/null
+++ b/packages/stream-management/bind2.test.js
@@ -0,0 +1,96 @@
+import { mockClient } from "@xmpp/test";
+
+test("enable", async () => {
+ const { entity, streamManagement: sm } = mockClient();
+
+ entity.mockInput(
+
+
+ PLAIN
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ const stanza_out = await entity.catchOutgoing();
+ expect(stanza_out).toEqual(
+
+ {stanza_out.getChild("initial-response")}
+
+
+
+ ,
+ );
+
+ expect(sm.enabled).toBe(false);
+ expect(sm.id).toBe("");
+ expect(sm.max).toBe(null);
+
+ entity.mockInput(
+
+
+
+
+ ,
+ );
+
+ expect(sm.enabled).toBe(true);
+ expect(sm.id).toBe("2j44j2");
+ expect(sm.max).toBe("600");
+});
+
+// https://xmpp.org/extensions/xep-0198.html#example-29
+test("Client failed to enable stream management", async () => {
+ const { entity, streamManagement: sm } = mockClient();
+
+ entity.mockInput(
+
+
+ PLAIN
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ const stanza_out = await entity.catchOutgoing();
+ expect(stanza_out).toEqual(
+
+ {stanza_out.getChild("initial-response")}
+
+
+
+ ,
+ );
+
+ expect(sm.enabled).toBe(false);
+ expect(sm.id).toBe("");
+ expect(sm.max).toBe(null);
+
+ entity.mockInput(
+
+
+
+
+
+
+ ,
+ );
+
+ expect(sm.enabled).toBe(false);
+ expect(sm.id).toBe("");
+ expect(sm.max).toBe(null);
+});
diff --git a/packages/stream-management/index.js b/packages/stream-management/index.js
index 4ec2bff7..1cabd870 100644
--- a/packages/stream-management/index.js
+++ b/packages/stream-management/index.js
@@ -4,10 +4,20 @@ import xml from "@xmpp/xml";
const NS = "urn:xmpp:sm:3";
-async function enable(entity, resume, max) {
- await entity.send(
- xml("enable", { xmlns: NS, max, resume: resume ? "true" : undefined }),
- );
+function makeEnableElement({ sm }) {
+ return xml("enable", {
+ xmlns: NS,
+ max: sm.preferredMaximum,
+ resume: sm.allowResume ? "true" : undefined,
+ });
+}
+
+function makeResumeElement({ sm }) {
+ return xml("resume", { xmlns: NS, h: sm.inbound, previd: sm.id });
+}
+
+async function enable(entity, sm) {
+ await entity.send(makeEnableElement({ sm }));
return new Promise((resolve, reject) => {
function listener(nonza) {
@@ -26,10 +36,8 @@ async function enable(entity, resume, max) {
});
}
-async function resume(entity, h, previd) {
- const response = await entity.sendReceive(
- xml("resume", { xmlns: NS, h, previd }),
- );
+async function resume(entity, sm) {
+ const response = await entity.sendReceive(makeResumeElement({ sm }));
if (!response.is("resumed", NS)) {
throw response;
@@ -42,9 +50,9 @@ export default function streamManagement({
streamFeatures,
entity,
middleware,
+ bind2,
+ sasl2,
}) {
- let address = null;
-
const sm = {
allowResume: true,
preferredMaximum: null,
@@ -55,6 +63,26 @@ export default function streamManagement({
max: null,
};
+ let address = null;
+
+ function resumed() {
+ sm.enabled = true;
+ if (address) entity.jid = address;
+ entity.status = "online";
+ }
+
+ function failed() {
+ sm.enabled = false;
+ sm.id = "";
+ sm.outbound = 0;
+ }
+
+ function enabled({ id, max }) {
+ sm.enabled = true;
+ sm.id = id;
+ sm.max = max;
+ }
+
entity.on("online", (jid) => {
address = jid;
sm.outbound = 0;
@@ -83,23 +111,46 @@ export default function streamManagement({
return next();
});
+ if (bind2) {
+ setupBind2({ bind2, sm, failed, enabled });
+ }
+ if (sasl2) {
+ setupSasl2({ sasl2, sm, failed, resumed });
+ }
+ if (streamFeatures) {
+ setupStreamFeature({
+ streamFeatures,
+ sm,
+ entity,
+ resumed,
+ failed,
+ enabled,
+ });
+ }
+
+ return sm;
+}
+
+function setupStreamFeature({
+ streamFeatures,
+ sm,
+ entity,
+ resumed,
+ failed,
+ enabled,
+}) {
// 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) {
try {
- await resume(entity, sm.inbound, sm.id);
- sm.enabled = true;
- entity.jid = address;
- entity.status = "online";
+ await resume(entity, sm);
+ resumed();
return true;
// If resumption fails, continue with session establishment
} catch {
- sm.id = "";
- sm.enabled = false;
- sm.outbound = 0;
+ failed();
}
}
@@ -108,22 +159,54 @@ export default function streamManagement({
// Resource binding first
await next();
- const promiseEnable = enable(entity, sm.allowResume, sm.preferredMaximum);
+ const promiseEnable = enable(entity, sm);
// > The counter for an entity's own sent stanzas is set to zero and started after sending either or .
sm.outbound = 0;
try {
const response = await promiseEnable;
- sm.enabled = true;
- sm.id = response.attrs.id;
- sm.max = response.attrs.max;
+ enabled(response.attrs);
} catch {
sm.enabled = false;
}
sm.inbound = 0;
});
+}
- return sm;
+function setupSasl2({ sasl2, sm, failed, resumed }) {
+ sasl2.use(
+ "urn:xmpp:sm:3",
+ (element) => {
+ if (!element.is("sm")) return;
+ if (sm.id) return makeResumeElement({ sm });
+ },
+ (element) => {
+ if (element.is("resumed")) {
+ resumed();
+ } else if (element.is(failed)) {
+ // const error = StreamError.fromElement(element)
+ failed();
+ }
+ },
+ );
+}
+
+function setupBind2({ bind2, sm, failed, enabled }) {
+ bind2.use(
+ "urn:xmpp:sm:3",
+ // https://xmpp.org/extensions/xep-0198.html#inline-examples
+ (_element) => {
+ return makeEnableElement({ sm });
+ },
+ (element) => {
+ if (element.is("enabled")) {
+ enabled(element.attrs);
+ } else if (element.is("failed")) {
+ // const error = StreamError.fromElement(element)
+ failed();
+ }
+ },
+ );
}
diff --git a/packages/stream-management/sasl2.test.js b/packages/stream-management/sasl2.test.js
new file mode 100644
index 00000000..697fc2d2
--- /dev/null
+++ b/packages/stream-management/sasl2.test.js
@@ -0,0 +1,78 @@
+import { mockClient } from "@xmpp/test";
+
+test("resume", async () => {
+ const { entity, streamManagement: sm } = mockClient();
+
+ sm.id = Math.random().toString().slice(2);
+
+ entity.mockInput(
+
+
+ PLAIN
+
+
+
+
+ ,
+ );
+
+ sm.outbound = 45;
+ sm.inbound = 54;
+
+ // eslint-disable-next-line unicorn/no-await-expression-member
+ const element_resume = (await entity.catchOutgoing()).getChild("resume");
+ element_resume.parent = null;
+ expect(element_resume).toEqual(
+ ,
+ );
+
+ entity.mockInput(
+
+
+ ,
+ );
+
+ expect(entity.streamManagement.outbound).toBe(45);
+ expect(entity.streamManagement.inbound).toBe(54);
+ expect(entity.streamManagement.enabled).toBe(true);
+});
+
+// https://xmpp.org/extensions/xep-0198.html#example-30
+test("Client failed to resume stream", async () => {
+ const { entity, streamManagement: sm } = mockClient();
+
+ sm.id = Math.random().toString().slice(2);
+
+ entity.mockInput(
+
+
+ PLAIN
+
+
+
+
+ ,
+ );
+
+ sm.outbound = 45;
+ sm.inbound = 54;
+
+ // eslint-disable-next-line unicorn/no-await-expression-member
+ const element_resume = (await entity.catchOutgoing()).getChild("resume");
+ element_resume.parent = null;
+ expect(element_resume).toEqual(
+ ,
+ );
+
+ entity.mockInput(
+
+
+
+
+ ,
+ );
+
+ expect(entity.streamManagement.outbound).toBe(45);
+ expect(entity.streamManagement.inbound).toBe(54);
+ expect(entity.streamManagement.enabled).toBe(false);
+});
diff --git a/packages/stream-management/test.js b/packages/stream-management/stream-features.test.js
similarity index 100%
rename from packages/stream-management/test.js
rename to packages/stream-management/stream-features.test.js
diff --git a/server/prosody.cfg.lua b/server/prosody.cfg.lua
index 56f05b3a..c917b4a2 100644
--- a/server/prosody.cfg.lua
+++ b/server/prosody.cfg.lua
@@ -24,9 +24,9 @@ modules_enabled = {
"smacks";
"sasl2";
"sasl2_bind2";
+ "sasl2_sm";
-- https://github.com/xmppjs/xmpp.js/pull/1006
-- "sasl2_fast";
- -- "sasl2_sm";
};
modules_disabled = {